newo
Version:
NEWO CLI: Professional command-line tool with modular architecture for NEWO AI Agent development. Features IDN-based file management, real-time progress tracking, intelligent sync operations, and comprehensive multi-customer support.
415 lines (352 loc) • 15.4 kB
text/typescript
import fs from 'fs-extra';
import path from 'path';
import axios, { AxiosError } from 'axios';
import { ENV } from './env.js';
import { customerStateDir } from './fsutil.js';
import type { TokenResponse, StoredTokens, CustomerConfig } from './types.js';
const STATE_DIR = path.join(process.cwd(), '.newo');
// Constants for validation and timeouts
const API_KEY_MIN_LENGTH = 10;
const TOKEN_MIN_LENGTH = 20;
const REQUEST_TIMEOUT = 30000; // 30 seconds
const TOKEN_EXPIRY_BUFFER = 60000; // 1 minute buffer for token expiry
// Validation functions
function validateApiKey(apiKey: string, customerIdn?: string): void {
if (!apiKey || typeof apiKey !== 'string') {
throw new Error(`Invalid API key format${customerIdn ? ` for customer ${customerIdn}` : ''}: must be a non-empty string`);
}
if (apiKey.length < API_KEY_MIN_LENGTH) {
throw new Error(`API key too short${customerIdn ? ` for customer ${customerIdn}` : ''}: minimum ${API_KEY_MIN_LENGTH} characters required`);
}
if (apiKey.includes(' ') || apiKey.includes('\n') || apiKey.includes('\t')) {
throw new Error(`Invalid API key format${customerIdn ? ` for customer ${customerIdn}` : ''}: contains invalid characters`);
}
}
function validateTokens(tokens: StoredTokens): void {
if (!tokens.access_token || typeof tokens.access_token !== 'string' || tokens.access_token.length < TOKEN_MIN_LENGTH) {
throw new Error('Invalid access token format: must be a non-empty string with minimum length');
}
if (tokens.refresh_token && (typeof tokens.refresh_token !== 'string' || tokens.refresh_token.length < TOKEN_MIN_LENGTH)) {
throw new Error('Invalid refresh token format: must be a non-empty string with minimum length');
}
if (tokens.expires_at && (typeof tokens.expires_at !== 'number' || tokens.expires_at <= 0)) {
throw new Error('Invalid token expiry: must be a positive number');
}
}
function validateUrl(url: string, name: string): void {
if (!url || typeof url !== 'string') {
throw new Error(`${name} must be a non-empty string`);
}
try {
new URL(url);
} catch {
throw new Error(`${name} must be a valid URL format`);
}
if (!url.startsWith('https://') && !url.startsWith('http://')) {
throw new Error(`${name} must use HTTP or HTTPS protocol`);
}
}
// Enhanced logging function
function logAuthEvent(level: 'info' | 'warn' | 'error', message: string, meta?: Record<string, unknown>): void {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
level,
module: 'auth',
message,
...meta
};
// Sanitize sensitive data
const sanitized = JSON.parse(JSON.stringify(logEntry, (key, value) => {
if (typeof key === 'string' && (key.toLowerCase().includes('key') || key.toLowerCase().includes('token') || key.toLowerCase().includes('secret'))) {
return typeof value === 'string' ? `${value.slice(0, 8)}...` : value;
}
return value;
}));
if (level === 'error') {
console.error(JSON.stringify(sanitized));
} else if (level === 'warn') {
console.warn(JSON.stringify(sanitized));
} else {
console.log(JSON.stringify(sanitized));
}
}
// Enhanced error handling for network requests
function handleNetworkError(error: unknown, operation: string, customerIdn?: string): never {
const customerInfo = customerIdn ? ` for customer ${customerIdn}` : '';
if (error instanceof AxiosError) {
const statusCode = error.response?.status;
const responseData = error.response?.data;
if (statusCode === 401) {
throw new Error(`Authentication failed${customerInfo}: Invalid API key or credentials`);
} else if (statusCode === 403) {
throw new Error(`Access forbidden${customerInfo}: Insufficient permissions`);
} else if (statusCode === 429) {
throw new Error(`Rate limit exceeded${customerInfo}: Please try again later`);
} else if (statusCode && statusCode >= 500) {
throw new Error(`Server error${customerInfo}: The NEWO service is temporarily unavailable (${statusCode})`);
} else if (error.code === 'ECONNREFUSED') {
throw new Error(`Connection refused${customerInfo}: Cannot reach NEWO service`);
} else if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
throw new Error(`Network timeout${customerInfo}: Check your internet connection`);
} else {
throw new Error(`Network error during ${operation}${customerInfo}: ${error.message}${responseData ? ` - ${JSON.stringify(responseData)}` : ''}`);
}
}
throw new Error(`Failed to ${operation}${customerInfo}: ${error instanceof Error ? error.message : String(error)}`);
}
function tokensPath(customerIdn?: string): string {
if (customerIdn) {
return path.join(customerStateDir(customerIdn), 'tokens.json');
}
return path.join(STATE_DIR, 'tokens.json'); // Legacy path
}
async function saveTokens(tokens: StoredTokens, customerIdn?: string): Promise<void> {
try {
validateTokens(tokens);
const filePath = tokensPath(customerIdn);
await fs.ensureDir(path.dirname(filePath));
await fs.writeJson(filePath, tokens, { spaces: 2 });
logAuthEvent('info', 'Tokens saved successfully', {
customerIdn: customerIdn || 'legacy',
expiresAt: tokens.expires_at ? new Date(tokens.expires_at).toISOString() : undefined,
hasRefreshToken: !!tokens.refresh_token
});
} catch (error: unknown) {
logAuthEvent('error', 'Failed to save tokens', {
customerIdn: customerIdn || 'legacy',
error: error instanceof Error ? error.message : String(error)
});
throw new Error(`Failed to save authentication tokens${customerIdn ? ` for customer ${customerIdn}` : ''}: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function loadTokens(customerIdn?: string): Promise<StoredTokens | null> {
try {
const filePath = tokensPath(customerIdn);
if (await fs.pathExists(filePath)) {
const tokens = await fs.readJson(filePath) as StoredTokens;
// Validate loaded tokens
try {
validateTokens(tokens);
} catch (validationError: unknown) {
logAuthEvent('warn', 'Loaded tokens failed validation, will regenerate', {
customerIdn: customerIdn || 'legacy',
error: validationError instanceof Error ? validationError.message : String(validationError)
});
return null; // Force token regeneration
}
logAuthEvent('info', 'Tokens loaded successfully', {
customerIdn: customerIdn || 'legacy',
expiresAt: tokens.expires_at ? new Date(tokens.expires_at).toISOString() : undefined,
hasRefreshToken: !!tokens.refresh_token
});
return tokens;
}
} catch (error: unknown) {
logAuthEvent('warn', 'Failed to load tokens from file', {
customerIdn: customerIdn || 'legacy',
error: error instanceof Error ? error.message : String(error)
});
}
// Fallback to environment tokens for legacy mode or bootstrap
if (!customerIdn && (ENV.NEWO_ACCESS_TOKEN || ENV.NEWO_REFRESH_TOKEN)) {
const tokens: StoredTokens = {
access_token: ENV.NEWO_ACCESS_TOKEN || '',
refresh_token: ENV.NEWO_REFRESH_TOKEN || '',
expires_at: Date.now() + 10 * 60 * 1000
};
await saveTokens(tokens);
return tokens;
}
return null;
}
function isExpired(tokens: StoredTokens | null): boolean {
if (!tokens?.expires_at) {
logAuthEvent('warn', 'Token has no expiry time, treating as expired');
return true;
}
const currentTime = Date.now();
const expiryTime = tokens.expires_at;
const timeUntilExpiry = expiryTime - currentTime;
if (timeUntilExpiry <= TOKEN_EXPIRY_BUFFER) {
logAuthEvent('info', 'Token is expired or expires soon', {
expiresAt: new Date(expiryTime).toISOString(),
timeUntilExpiry: Math.round(timeUntilExpiry / 1000)
});
return true;
}
return false;
}
function normalizeTokenResponse(tokenResponse: TokenResponse): { access: string; refresh: string; expiresInSec: number } {
const access = tokenResponse.access_token || tokenResponse.token || tokenResponse.accessToken;
const refresh = tokenResponse.refresh_token || tokenResponse.refreshToken || '';
const expiresInSec = tokenResponse.expires_in || tokenResponse.expiresIn || 3600;
if (!access) {
throw new Error('Invalid token response: missing access token');
}
return { access, refresh, expiresInSec };
}
export async function exchangeApiKeyForToken(customer?: CustomerConfig): Promise<StoredTokens> {
const apiKey = customer?.apiKey || ENV.NEWO_API_KEY;
const customerIdn = customer?.idn;
// Validate inputs
if (!apiKey) {
throw new Error(customer
? `API key not set for customer ${customer.idn}. Set NEWO_CUSTOMER_${customer.idn.toUpperCase()}_API_KEY in your environment`
: 'NEWO_API_KEY not set. Provide an API key in .env file'
);
}
validateApiKey(apiKey, customerIdn);
validateUrl(ENV.NEWO_BASE_URL, 'NEWO_BASE_URL');
logAuthEvent('info', 'Exchanging API key for tokens', { customerIdn: customerIdn || 'legacy' });
try {
const url = `${ENV.NEWO_BASE_URL}/api/v1/auth/api-key/token`;
const response = await axios.post<TokenResponse>(
url,
{},
{
timeout: REQUEST_TIMEOUT,
headers: {
'x-api-key': apiKey,
'accept': 'application/json',
'user-agent': 'newo-cli/1.5.0'
}
}
);
if (!response.data) {
throw new Error('Empty response from token exchange endpoint');
}
const { access, refresh, expiresInSec } = normalizeTokenResponse(response.data);
const tokens: StoredTokens = {
access_token: access,
refresh_token: refresh,
expires_at: Date.now() + expiresInSec * 1000
};
// Validate tokens before saving
validateTokens(tokens);
await saveTokens(tokens, customerIdn);
logAuthEvent('info', 'API key exchange completed successfully', {
customerIdn: customerIdn || 'legacy',
expiresAt: new Date(tokens.expires_at).toISOString()
});
return tokens;
} catch (error: unknown) {
logAuthEvent('error', 'API key exchange failed', {
customerIdn: customerIdn || 'legacy',
error: error instanceof Error ? error.message : String(error)
});
handleNetworkError(error, 'exchange API key for token', customerIdn);
}
}
export async function refreshWithEndpoint(refreshToken: string, customer?: CustomerConfig): Promise<StoredTokens> {
const customerIdn = customer?.idn;
// Validate inputs
if (!ENV.NEWO_REFRESH_URL) {
throw new Error('NEWO_REFRESH_URL not set in environment');
}
if (!refreshToken || typeof refreshToken !== 'string' || refreshToken.length < TOKEN_MIN_LENGTH) {
throw new Error(`Invalid refresh token${customerIdn ? ` for customer ${customerIdn}` : ''}: must be a non-empty string with minimum length`);
}
validateUrl(ENV.NEWO_REFRESH_URL, 'NEWO_REFRESH_URL');
logAuthEvent('info', 'Refreshing tokens using refresh endpoint', { customerIdn: customerIdn || 'legacy' });
try {
const response = await axios.post<TokenResponse>(
ENV.NEWO_REFRESH_URL,
{ refresh_token: refreshToken },
{
timeout: REQUEST_TIMEOUT,
headers: {
'accept': 'application/json',
'user-agent': 'newo-cli/1.5.0'
}
}
);
if (!response.data) {
throw new Error('Empty response from token refresh endpoint');
}
const { access, expiresInSec } = normalizeTokenResponse(response.data);
const refresh = response.data.refresh_token || response.data.refreshToken || refreshToken;
const tokens: StoredTokens = {
access_token: access,
refresh_token: refresh,
expires_at: Date.now() + expiresInSec * 1000
};
// Validate tokens before saving
validateTokens(tokens);
await saveTokens(tokens, customerIdn);
logAuthEvent('info', 'Token refresh completed successfully', {
customerIdn: customerIdn || 'legacy',
expiresAt: new Date(tokens.expires_at).toISOString()
});
return tokens;
} catch (error: unknown) {
logAuthEvent('error', 'Token refresh failed', {
customerIdn: customerIdn || 'legacy',
error: error instanceof Error ? error.message : String(error)
});
handleNetworkError(error, 'refresh token', customerIdn);
}
}
export async function getValidAccessToken(customer?: CustomerConfig): Promise<string> {
const customerIdn = customer?.idn;
logAuthEvent('info', 'Getting valid access token', { customerIdn: customerIdn || 'legacy' });
try {
let tokens = await loadTokens(customerIdn);
// No tokens found, exchange API key
if (!tokens || !tokens.access_token) {
logAuthEvent('info', 'No existing tokens found, exchanging API key', { customerIdn: customerIdn || 'legacy' });
tokens = await exchangeApiKeyForToken(customer);
return tokens.access_token;
}
// Tokens are valid and not expired
if (!isExpired(tokens)) {
logAuthEvent('info', 'Using existing valid access token', { customerIdn: customerIdn || 'legacy' });
return tokens.access_token;
}
// Try to refresh if refresh URL and token available
if (ENV.NEWO_REFRESH_URL && tokens.refresh_token) {
try {
logAuthEvent('info', 'Attempting to refresh expired token', { customerIdn: customerIdn || 'legacy' });
tokens = await refreshWithEndpoint(tokens.refresh_token, customer);
return tokens.access_token;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
logAuthEvent('warn', 'Token refresh failed, falling back to API key exchange', {
customerIdn: customerIdn || 'legacy',
error: message
});
}
} else {
logAuthEvent('info', 'No refresh endpoint or refresh token available, using API key exchange', {
customerIdn: customerIdn || 'legacy'
});
}
// Fallback to API key exchange
tokens = await exchangeApiKeyForToken(customer);
return tokens.access_token;
} catch (error: unknown) {
logAuthEvent('error', 'Failed to get valid access token', {
customerIdn: customerIdn || 'legacy',
error: error instanceof Error ? error.message : String(error)
});
throw new Error(`Unable to obtain valid access token${customerIdn ? ` for customer ${customerIdn}` : ''}: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function forceReauth(customer?: CustomerConfig): Promise<string> {
const customerIdn = customer?.idn;
logAuthEvent('info', 'Forcing re-authentication', { customerIdn: customerIdn || 'legacy' });
try {
const tokens = await exchangeApiKeyForToken(customer);
logAuthEvent('info', 'Forced re-authentication completed successfully', {
customerIdn: customerIdn || 'legacy',
expiresAt: new Date(tokens.expires_at).toISOString()
});
return tokens.access_token;
} catch (error: unknown) {
logAuthEvent('error', 'Forced re-authentication failed', {
customerIdn: customerIdn || 'legacy',
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}