@oracle/suitecloud-cli
Version:
SuiteCloud CLI for Node.js
470 lines (407 loc) • 16 kB
JavaScript
/*
** Copyright (c) 2024 Oracle and/or its affiliates. All rights reserved.
** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
*/
;
/** http libraries */
const http = require('node:http');
const https = require('node:https');
const EventEmitter = require('events');
/** Events */
const EVENTS = {
PROXY_ERROR: {
DEFAULT: 'proxyError',
MANUAL_AUTH_REFRESH_REQUIRED: 'manualAuthRefreshRequired'
},
REQUEST_ERROR: {
PATH_NOT_ALLOWED: 'requestPathNotAllowed',
UNAUTHORIZED: 'unauthorizedProxyRequest'
},
SERVER_ERROR: {
DEFAULT: 'serverError',
ON_AUTH_REFRESH: 'serverErrorOnRefresh',
},
}
/** Authentication methods */
const {
getAuthIds,
checkIfReauthorizationIsNeeded,
forceRefreshAuthorization,
} = require('../utils/AuthenticationUtils');
const {
AUTHORIZATION_PROPERTIES_KEYS,
HTTP_RESPONSE_CODE,
} = require('../ApplicationConstants');
/** Message literal service method */
const NodeTranslationService = require('./NodeTranslationService');
const {
SUITECLOUD_AUTH_PROXY_SERVICE,
} = require('./TranslationKeys');
const MAX_RETRY_ATTEMPTS = 1;
const LOCAL_HOSTNAME = '127.0.0.1';
const DEVASSIST_CHAT_COMPLETIONS_PATH = '/api/internal/devassist/chat/completions'
/** Target server port */
const TARGET_SERVER_PORT = 443;
class SuiteCloudAuthProxyService extends EventEmitter {
constructor(sdkPath, executionEnvironmentContext, allowedPathPrefix, apiKey) {
super();
this._sdkPath = sdkPath;
this._executionEnvironmentContext = executionEnvironmentContext;
this._allowedPathPrefix = allowedPathPrefix;
this._apiKey = apiKey;
/** These are the variables we are going to use to store instance data */
this._accessToken = undefined;
this._localProxy = undefined;
this._targetHost = undefined;
this._authId = undefined;
}
/**
* starts the listener.
* It can return an error, for instance when it cannot connect to the auth server or the parameters being incorrect
* @public
* @param authId
* @param proxyPort
* @returns {Promise<void>}
*/
async start(authId, proxyPort) {
//Parameters validation
this._evalInputParameters(authId, proxyPort);
this._authId = authId;
//Retrieve from authId accessToken and target host
const { accessToken, hostName } = await this._retrieveCredentials();
this._targetHost = hostName;
this._accessToken = accessToken;
await this.stop();
this._localProxy = http.createServer();
this._localProxy.addListener('request', async (request, response) => {
// Validate incoming request (Api Key & Request Path)
if (!this._isValidAndFilterIncomingRequest(request, response)) {
return
}
const requestOptions = this._buildRequestOptions(request);
// Save body
const bodyChunks = [];
request.on('data', function (chunk) {
bodyChunks.push(chunk);
});
request.on('end', async () => {
const body = Buffer.concat(bodyChunks);
const proxyRequest = await this._createProxyRequest(requestOptions, body, response, 0);
proxyRequest.write(body);
proxyRequest.end();
});
});
this._localProxy.listen(proxyPort, LOCAL_HOSTNAME, () => {
const localURL = `http://${LOCAL_HOSTNAME}:${proxyPort}`;
console.log(`SuiteCloud Proxy server listening on ${localURL}`);
});
this._localProxy.on('error', (error) => {
const errorMessage = (error.code === 'EADDRINUSE') ?
NodeTranslationService.getMessage(SUITECLOUD_AUTH_PROXY_SERVICE.ALREADY_USED_PORT, proxyPort, error.message ?? '')
: NodeTranslationService.getMessage(SUITECLOUD_AUTH_PROXY_SERVICE.INTERNAL_PROXY_SERVER_ERROR, proxyPort, error.message ?? '');
this._emitEventWithData(EVENTS.PROXY_ERROR.DEFAULT, errorMessage);
});
}
/**
* Public method that stops the proxy and returns a Promise resolved when it's fully closed
* @public
*/
async stop() {
// when having a "listen EADDRINUSE: address already in use 127.0.0.1:49285" the server instance exists but is not listening
// to avoid "Error [ERR_SERVER_NOT_RUNNING]: Server is not running." at close time there is the need to check if server is activelly listening
if (this._localProxy && this._localProxy.listening) {
// Wrap the close callback in a Promise
const closePromise = new Promise((resolve, reject) => {
this._localProxy.close(error => {
if (error) {
console.error('Error occurred while stopping SuiteCloud Auth Proxy server.')
console.error(error)
reject(error);
} else {
console.log('SuiteCloud Auth Proxy server stopped.');
resolve();
}
});
});
await closePromise;
} else {
console.log('No server instance to stop.');
}
this._localProxy = null;
}
/**
* Updates the stored API key, which is used to authenticate and filter incoming requests to the local server.
*
* @public
* @param {string} newApiKey - The new API key to set for request filtering.
* @returns {void}
*/
updateApiKey(newApiKey) {
this._apiKey = newApiKey;
}
/**
* For being used after manual authentication. It refreshes the access token from credentials.
* @public
* @returns {Promise<void>}
*/
async reloadAccessToken() {
const { accessToken } = await this._retrieveCredentials();
this._accessToken = accessToken;
console.log('access token refreshed');
}
/**
* Emits an event with a structured data object.
*
* @param {string} eventName - The name of the event to emit.
* @param {string} errorMessage - The error message to include in the data object.
* @param {string} [requestUrl] - (Optional) The URL associated with the event, if applicable.
* @returns {void}
* @private
*/
_emitEventWithData(eventName, errorMessage, requestUrl) {
const emitData = {
authId: this._authId,
message: errorMessage,
...(requestUrl && { requestUrl })
}
console.error({ eventName, emitData });
this.emit(eventName, emitData);
}
/**
* It validates the input parameters
* @param authId
* @param proxyPort
* @private
*/
_evalInputParameters(authId, proxyPort) {
if (!authId) {
throw NodeTranslationService.getMessage(SUITECLOUD_AUTH_PROXY_SERVICE.MISSING_AUTH_ID);
}
if (!proxyPort) {
throw NodeTranslationService.getMessage(SUITECLOUD_AUTH_PROXY_SERVICE.MISSING_PORT);
}
if (isNaN(proxyPort)) {
throw NodeTranslationService.getMessage(SUITECLOUD_AUTH_PROXY_SERVICE.PORT_MUST_BE_NUMBER);
}
}
/**
* This method retrieves the credentials and returns the hostname and the accessToken
* @returns {Promise<{hostName: string, accessToken: string}>}
* @private
*/
async _retrieveCredentials() {
const authIDActionResult = await getAuthIds(this._sdkPath);
if (!authIDActionResult.isSuccess()) {
throw authIDActionResult.errorMessages;
}
if (!authIDActionResult.data.hasOwnProperty(this._authId)) {
throw NodeTranslationService.getMessage(SUITECLOUD_AUTH_PROXY_SERVICE.NOT_EXISTING_AUTH_ID, this._authId);
}
return {
accessToken: authIDActionResult.data[this._authId].token.accessToken,
hostName: authIDActionResult.data[this._authId].hostInfo.hostName,
};
}
/**
* Validates an incoming proxy request for apiKey & allowed path.
* Responds and emits the correct events on failure.
* @param {http.IncomingMessage} request
* @param {http.ServerResponse} response
* @returns {boolean} true if valid, false if rejected
*/
_isValidAndFilterIncomingRequest(request, response) {
const requestValidationFunctions = [
this._checkAuthenticationHeader.bind(this),
this._checkRequestPath.bind(this)
];
// check all validations are true or stops in the first failing one and returns false
return requestValidationFunctions.every((validateFunction => validateFunction(request, response)));
}
/**
* Authentication filter: check authorization header if an API key is configured.
* Manages response and emit event on failure
*/
_checkAuthenticationHeader(request, response) {
if (this._apiKey) {
const authHeader = request.headers['authorization'];
if (authHeader !== `Bearer ${this._apiKey}`) {
const unauthorizedMessage = NodeTranslationService.getMessage(SUITECLOUD_AUTH_PROXY_SERVICE.UNAUTHORIZED_PROXY_REQUEST);
// using 401-Unauthorized http response code as CLINE won't activate the retry mechanism with it
// not using 407-Proxy Authentication Required as CLINE activates the retry mechanism with it
this._writeResponseMessage(response, HTTP_RESPONSE_CODE.UNAUTHORIZED, unauthorizedMessage);
this._emitEventWithData(EVENTS.REQUEST_ERROR.UNAUTHORIZED, unauthorizedMessage, request.url)
return false;
}
}
return true;
}
/**
* Allowed path filter: check allowed prefix if configured.
* Manages response and emit event on failure
*/
_checkRequestPath(request, response) {
// Allowed path filter: check allowed prefix if configured
if (this._allowedPathPrefix && !request.url.startsWith(this._allowedPathPrefix)) {
const errorMessage = NodeTranslationService.
getMessage(SUITECLOUD_AUTH_PROXY_SERVICE.REQUEST_PATH_NOT_ALLOWED_ERROR, this._allowedPathPrefix);
this._writeResponseMessage(response, HTTP_RESPONSE_CODE.FORBIDDEN, errorMessage);
this._emitEventWithData(EVENTS.REQUEST_ERROR.PATH_NOT_ALLOWED, errorMessage);
return false;
}
return true;
}
/**
* Builds request options
* @param request request
* @returns {{path: *, headers: *&{authorization: string}, hostname: *, method: *, port: number}}
* @private
*/
_buildRequestOptions(request) {
const authorization = 'Bearer ' + this._accessToken;
const host = this._targetHost;
const requestOptions = {
hostname: this._targetHost,
port: TARGET_SERVER_PORT,
path: request.url,
method: request.method,
headers: { ...request.headers, host, authorization },
};
// added to get "stream responses" from netsuite devassist backend
if (request?.url.startsWith(DEVASSIST_CHAT_COMPLETIONS_PATH)) {
requestOptions.headers = {
...requestOptions.headers,
Accept: 'text/event-stream'
};
}
// Add agent for insecure connections when connecting to runboxes
if (this._targetHost && this._targetHost.includes('vm.eng')) {
requestOptions.agent = new https.Agent({
rejectUnauthorized: false,
});
requestOptions.rejectUnauthorized = false;
}
return requestOptions;
}
async _createProxyRequest(requestOptions, body, response, attempts) {
const proxyRequest = https.request(requestOptions, async (proxyResponse) => {
if (proxyResponse.statusCode === HTTP_RESPONSE_CODE.UNAUTHORIZED && attempts <= MAX_RETRY_ATTEMPTS) {
proxyResponse.resume();
const refreshOperationResult = await this._tryRefreshOperation();
if (refreshOperationResult.accessTokenHasBeenUpdated) {
this._updateRequestAuthorizationHeader(requestOptions);
const newProxyRequest = await this._createProxyRequest(requestOptions, body, response, attempts + 1);
newProxyRequest.write(body);
newProxyRequest.end();
} else {
this._emitEventWithData(refreshOperationResult.emitEventName, refreshOperationResult.errorMessage);
//Message shown to cline
this._writeResponseMessage(response, refreshOperationResult.responseStatusCode, refreshOperationResult.errorMessage);
proxyResponse.pipe(response, { end: true });
}
} else {
response.writeHead(proxyResponse.statusCode || HTTP_RESPONSE_CODE.INTERNAL_SERVER_ERROR, proxyResponse.headers);
proxyResponse.pipe(response, { end: true });
}
});
proxyRequest.on('error', (error) => {
console.error('Proxy request error:', error);
this._emitEventWithData(EVENTS.SERVER_ERROR.DEFAULT, error.message)
response.writeHead(HTTP_RESPONSE_CODE.INTERNAL_SERVER_ERROR);
//TODO Review this message and see confluence error pages and review with the tech writers
response.end('SuiteCloud Proxy error: ' + error.message);
});
return proxyRequest;
}
/**
* This method refreshes authorization.
* If successful returns true and updates this._accessToken
* If not successful returns false and returns the http response and the info to generate the emit message.
* @returns {Promise<*>}
* @private
*/
async _tryRefreshOperation() {
const refreshInfo = {
accessTokenHasBeenUpdated: false,//Whether the token has been updated or not.
emitEventName: undefined, //Event to be thrown.
responseStatusCode: undefined, //HTTP response status code.
errorMessage: undefined //Error message, used both for emit data and http response.
};
const inspectAuthOperationResult = await checkIfReauthorizationIsNeeded(this._authId, this._sdkPath, this._executionEnvironmentContext);
//Not being able to execute the reauth if needed, it can be vpn disconnected, network problems...
if (!inspectAuthOperationResult.isSuccess()) {
const errorMsg = this._cleanText(inspectAuthOperationResult.errorMessages.join('. '));
refreshInfo.emitEventName = EVENTS.SERVER_ERROR.ON_AUTH_REFRESH;
refreshInfo.errorMessage = errorMsg;
refreshInfo.responseStatusCode = HTTP_RESPONSE_CODE.FORBIDDEN;
return Object.freeze(refreshInfo);
}
//Needs manual reauthorization
const inspectAuthData = inspectAuthOperationResult.data;
if (inspectAuthData[AUTHORIZATION_PROPERTIES_KEYS.NEEDS_REAUTHORIZATION]) {
const errorMsg = NodeTranslationService.getMessage(SUITECLOUD_AUTH_PROXY_SERVICE.NEED_TO_REAUTHENTICATE);
refreshInfo.emitEventName = EVENTS.PROXY_ERROR.MANUAL_AUTH_REFRESH_REQUIRED;
refreshInfo.errorMessage = errorMsg;
refreshInfo.responseStatusCode = HTTP_RESPONSE_CODE.FORBIDDEN;
return Object.freeze(refreshInfo);
}
//force refresh
const forceRefreshOperationResult = await forceRefreshAuthorization(this._authId, this._sdkPath, this._executionEnvironmentContext);
if (!forceRefreshOperationResult.isSuccess()) {
//Refresh unsuccessful
const errorMsg = this._cleanText(forceRefreshOperationResult.errorMessages.join('. '));
refreshInfo.emitEventName = EVENTS.PROXY_ERROR.MANUAL_AUTH_REFRESH_REQUIRED;
refreshInfo.errorMessage = errorMsg;
refreshInfo.responseStatusCode = HTTP_RESPONSE_CODE.FORBIDDEN;
return Object.freeze(refreshInfo);
} else {
//If the refresh has been successful
//Return operation forceRefreshOperationResult as true and update the accessToken
refreshInfo.accessTokenHasBeenUpdated = true;
this._accessToken = forceRefreshOperationResult.data.accessToken;
return Object.freeze(refreshInfo);
}
}
/**
* Method to clear output messages.
* The reason for this is the output do not show properly \n and \r
* So they are replaced by . and made some extra adjustments.
* @param input
* @returns {*}
*/
_cleanText(input) {
let result = input.replace(/\r/g, ''); // Remove \r
result = result.replace(/\n/g, '. '); // Replace \n with ". "
result = result.replace(/,\./g, '.'); // Replace ",." with "."
result = result.replace(/"/g, '\''); //Replace \" by ' since \" also show problems.
while (result.includes(' ')) { // Replace double spaces with single space
result = result.replace(/ /g, ' ');
}
while (result.includes('..')) { // Replace ".." with "."
result = result.replace(/\.\./g, '.');
}
return result;
}
/**
* Update requestOptions access token without need to rebuild the requestOptions
* @param requestOptions
* @private
*/
_updateRequestAuthorizationHeader(requestOptions) {
if (requestOptions.headers && requestOptions.headers.authorization) {
requestOptions.headers.authorization = 'Bearer ' + this._accessToken;
}
}
/**
* Write JSON response message
* @param response
* @param responseCode
* @param responseMessage
* @private
*/
_writeResponseMessage(response, responseCode, responseMessage) {
response.writeHead(responseCode, { 'Content-Type': 'application/json' });
const message = { error: responseMessage };
response.end(JSON.stringify(message));
}
}
module.exports = { SuiteCloudAuthProxyService, EVENTS };