module.exports = function (RED) {
'use strict';
const axios = require('axios');
const http = require('http');
const https = require('https');
const { URLSearchParams } = require('url'); // Use URLSearchParams for form data
const Logger = require('node-red-contrib-oauth2/src/libs/logger');
/**
* Class representing an OAuth2 Node.
*/
class OAuth2Node {
/**
* Create an OAuth2Node.
* @param {Object} config - Node configuration object.
*/
constructor(config) {
RED.nodes.createNode(this, config);
this.logger = new Logger({ name: 'identifier', count: null, active: config.debug || false, label: 'debug' });
this.logger.debug('Constructor: Initializing node with config', config);
// Node configuration properties
this.name = config.name || '';
this.container = config.container || '';
this.access_token_url = config.access_token_url || '';
this.redirect_uri = config.redirect_uri || '';
this.grant_type = config.grant_type || '';
this.refresh_token = config.refresh_token || '';
this.username = config.username || '';
this.password = config.password || '';
this.client_id = config.client_id || '';
this.client_secret = config.client_secret || '';
this.scope = config.scope || '';
this.resource = config.resource || '';
this.state = config.state || '';
this.rejectUnauthorized = config.rejectUnauthorized || false;
this.client_credentials_in_body = config.client_credentials_in_body || false;
this.headers = config.headers || {};
this.sendErrorsToCatch = config.senderr || false;
// Proxy settings from environment variables or configuration
this.prox = process.env.http_proxy || process.env.HTTP_PROXY || config.proxy;
this.noprox = (process.env.no_proxy || process.env.NO_PROXY || '').split(',');
this.logger.debug('Constructor: Finished setting up node properties');
// Register the input handler
this.on('input', this.onInput.bind(this));
this.host = RED.settings.uiHost || 'localhost';
this.logger.debug('Constructor: Node input handler registered');
}
/**
* Handles input messages.
* @param {Object} msg - Input message object.
* @param {Function} send - Function to send messages.
* @param {Function} done - Function to indicate processing is complete.
*/
async onInput(msg, send, done) {
// this.debug ? this.logger.setOn() : this.logger.setOff();
this.logger.debug('onInput: Received message', msg);
const options = this.generateOptions(msg); // Generate request options
this.logger.debug('onInput: Generated request options', options);
this.configureProxy(); // Configure proxy settings
this.logger.debug('onInput: Configured proxy settings', this.prox);
delete msg.oauth2Request; // Remove oauth2Request from msg
this.logger.debug('onInput: Removed oauth2Request from message');
options.form = this.cleanForm(options.form); // Clean the form data
this.logger.debug('onInput: Cleaned form data', options.form);
try {
const response = await this.makePostRequest(options); // Make the POST request
this.logger.debug('onInput: POST request response', response);
this.handleResponse(response, msg, send); // Handle the response
} catch (error) {
this.logger.error('onInput: Error making POST request', error);
this.handleError(error, msg, send); // Handle any errors
}
done(); // Indicate that processing is complete
this.logger.debug('onInput: Finished processing input');
}
/**
* Generates options for the HTTP request.
* @param {Object} msg - Input message object.
* @returns {Object} - The request options.
*/
generateOptions(msg) {
// Log the start of the option generation process with the input message
this.logger.debug('generateOptions: Configuring options with message', msg);
// Initialize the form object to hold the form data
let form = {};
// Set the default URL to the access token URL configured in the node
let url = this.access_token_url;
// Initialize headers with default Content-Type and Accept headers
let headers = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
};
// Retrieve credentials from the message if available, otherwise use an empty object
const creds = msg.oauth2Request ? msg.oauth2Request.credentials || {} : {};
// Initialize the form data with grant_type, scope, resource, and state
form = {
grant_type: creds.grant_type || this.grant_type,
scope: creds.scope || this.scope,
resource: creds.resource || this.resource,
state: creds.state || this.state
};
// Define functions for different OAuth2 flows
const flows = {
// Password flow function
password: () => {
this.logger.debug('generateOptions: Password flow detected');
form.username = creds.username || this.username;
form.password = creds.password || this.password;
},
// Client credentials flow function
client_credential: () => {
this.logger.debug('generateOptions: Client credentials flow detected');
form.client_id = creds.client_id || this.client_id;
form.client_secret = creds.client_secret || this.client_secret;
},
// Refresh token flow function
refresh_token: () => {
this.logger.debug('generateOptions: Refresh token flow detected');
form.client_id = creds.client_id || this.client_id;
form.client_secret = creds.client_secret || this.client_secret;
form.refresh_token = creds.refresh_token || this.refresh_token;
},
// Authorization code flow function
authorization_code: () => {
this.logger.debug('generateOptions: Authorization code flow detected');
const credentials = RED.nodes.getCredentials(this.id) || {};
if (credentials) {
form.code = credentials.code;
form.redirect_uri = this.redirect_uri;
}
},
// Implicit flow function
implicit_flow: () => {
this.logger.debug('generateOptions: Implicit flow detected');
const credentials = RED.nodes.getCredentials(this.id) || {};
if (credentials) {
form.client_id = this.client_id;
form.client_secret = this.client_secret;
form.code = credentials.code;
form.grant_type = 'authorization_code';
form.redirect_uri = this.redirect_uri;
}
},
// Set by credentials function
set_by_credentials: () => {
this.logger.debug('generateOptions: Set by credentials flow detected');
if (msg.oauth2Request) {
const credentials = msg.oauth2Request.credentials || {};
form.client_id = credentials.client_id || this.client_id;
form.client_secret = credentials.client_secret || this.client_secret;
form.refresh_token = credentials.refresh_token || '';
}
}
};
// Check if the grant type from the credentials is supported and call the corresponding function
if (creds.grant_type && flows[creds.grant_type]) {
flows[creds.grant_type]();
}
// Check if the default grant type of the node is supported and call the corresponding function
else if (this.grant_type && flows[this.grant_type]) {
flows[this.grant_type]();
}
// Check if client credentials should be included in the body
if (this.client_credentials_in_body) {
this.logger.debug('generateOptions: Client credentials in body detected, using credentials');
form.client_id = creds.client_id || this.client_id;
form.client_secret = creds.client_secret || this.client_secret;
} else {
// Otherwise, add the Authorization header with client credentials encoded in base64
headers.Authorization = 'Basic ' + Buffer.from(`${creds.client_id || this.client_id}:${creds.client_secret || this.client_secret}`).toString('base64');
}
// Set the URL to the access token URL from the message if available, otherwise use the default
url = msg.oauth2Request ? msg.oauth2Request.access_token_url || this.access_token_url : this.access_token_url;
// Log the final generated options
this.logger.debug('generateOptions: Returning options', { method: 'POST', url, headers, form });
// Return the HTTP request options
return {
method: 'POST',
url: url,
headers: { ...headers, ...this.headers },
rejectUnauthorized: this.rejectUnauthorized,
form: form
};
}
/**
* Configures proxy settings.
*/
configureProxy() {
if (!this.prox) return;
const proxyURL = new URL(this.prox);
this.proxy = {
protocol: proxyURL.protocol,
hostname: proxyURL.hostname,
port: proxyURL.port,
username: proxyURL.username || null,
password: proxyURL.password || null
};
this.logger.debug('configureProxy: Proxy configured', this.proxy);
}
/**
* Cleans form data by removing undefined or empty values.
* @param {Object} form - The form data.
* @returns {Object} - The cleaned form data.
*/
cleanForm(form) {
const cleanedForm = Object.fromEntries(Object.entries(form).filter(([, value]) => value !== undefined && value !== ''));
this.logger.debug('cleanForm: Cleaned form data', cleanedForm);
return cleanedForm;
}
/**
* Makes a POST request.
* @param {Object} options - The request options.
* @returns {Promise<Object>} - The response from the request.
*/
async makePostRequest(options) {
this.logger.debug('makePostRequest: Making POST request with options', options);
const axiosOptions = {
method: options.method,
url: options.url,
headers: options.headers,
data: new URLSearchParams(options.form).toString(),
proxy: false,
httpAgent: new http.Agent({ rejectUnauthorized: options.rejectUnauthorized }),
httpsAgent: new https.Agent({ rejectUnauthorized: options.rejectUnauthorized })
};
if (this.proxy) {
const HttpsProxyAgent = require('https-proxy-agent');
axiosOptions.httpsAgent = new HttpsProxyAgent(this.proxy);
}
this.logger.debug('makePostRequest: Axios request options prepared', axiosOptions);
return axios(axiosOptions).catch((error) => {
this.logger.error('makePostRequest: Error during POST request', error);
throw error;
});
}
/**
* Handles the response from the POST request.
* @param {Object} response - The response object.
* @param {Object} msg - Input message object.
* @param {Function} send - Function to send messages.
*/
handleResponse(response, msg, send) {
this.logger.debug('handleResponse: Handling response', response);
if (!response || !response.data) {
this.logger.warn('handleResponse: Invalid response data', response);
this.handleError({ message: 'Invalid response data' }, msg, send);
return;
}
msg.oauth2Response = response.data || {};
msg.headers = response.headers || {}; // Include headers in the message
this.setStatus('green', `HTTP ${response.status}, ok`);
this.logger.debug('handleResponse: Response data set in message', msg);
send(msg);
}
/**
* Handles errors from the POST request.
* @param {Object} error - The error object.
* @param {Object} msg - Input message object.
* @param {Function} send - Function to send messages.
*/
handleError(error, msg, send) {
this.logger.error('handleError: Handling error', error);
const status = error.response ? error.response.status : error.code;
const message = error.response ? error.response.statusText : error.message;
const data = error.response && error.response.data ? error.response.data : {};
const headers = error.response ? error.response.headers : {};
msg.oauth2Error = { status, message, data, headers };
this.setStatus('red', `HTTP ${status}, ${message}`);
this.logger.debug('handleError: Error data set in message', msg);
if (this.sendErrorsToCatch) {
send([null, msg]);
} else {
this.error(msg);
send([null, msg]);
}
}
/**
* Sets the status of the node.
* @param {string} color - The color of the status indicator.
* @param {string} text - The status text.
*/
setStatus(color, text) {
this.logger.debug('setStatus: Setting status', { color, text });
this.status({ fill: color, shape: 'dot', text });
setTimeout(() => {
this.status({});
}, 250);
}
}
/**
* Endpoint to retrieve OAuth2 credentials based on a token.
* @param {Object} req - The request object.
* @param {Object} res - The response object.
*/
RED.httpAdmin.get('/oauth2/credentials/:token', (req, res) => {
try {
const credentials = RED.nodes.getCredentials(req.params.token);
if (credentials) {
res.json({ code: credentials.code, redirect_uri: credentials.redirect_uri });
} else {
res.status(404).send('oauth2.error.no-credentials');
}
} catch (error) {
res.status(500).send('oauth2.error.server-error');
}
});
/**
* Endpoint to handle OAuth2 redirect and store the authorization code.
* @param {Object} req - The request object.
* @param {Object} res - The response object.
*/
RED.httpAdmin.get('/oauth2/redirect', (req, res) => {
if (req.query.code) {
const [node_id] = req.query.state.split(':');
let credentials = RED.nodes.getCredentials(node_id);
if (!credentials) {
credentials = {};
}
credentials = { ...credentials, ...req.query };
RED.nodes.addCredentials(node_id, credentials);
res.send(`
<HTML>
<HEAD>
<script language="javascript" type="text/javascript">
function closeWindow() {
window.open('', '_parent', '');
window.close();
}
function delay() {
setTimeout("closeWindow()", 1000);
}
</script>
</HEAD>
<BODY onload="javascript:delay();">
<p>Success! This page can be closed if it doesn't do so automatically.</p>
</BODY>
</HTML>
`);
} else {
res.status(400).send('oauth2.error.no-credentials');
}
});
// Register the OAuth2Node node type
RED.nodes.registerType('oauth2', OAuth2Node, {
credentials: {
clientId: { type: 'text' },
clientSecret: { type: 'password' },
accessToken: { type: 'password' },
refreshToken: { type: 'password' },
expireTime: { type: 'password' },
code: { type: 'password' }
}
});
};