@salesforce/core
Version:
Core libraries to interact with SFDX projects, orgs, and APIs.
383 lines • 14.8 kB
JavaScript
"use strict";
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebServer = exports.WebOAuthServer = void 0;
const http = require("http");
const querystring_1 = require("querystring");
const url_1 = require("url");
const net_1 = require("net");
const jsforce_1 = require("jsforce");
const kit_1 = require("@salesforce/kit");
const ts_types_1 = require("@salesforce/ts-types");
const logger_1 = require("./logger");
const org_1 = require("./org");
const sfError_1 = require("./sfError");
const messages_1 = require("./messages");
const sfProject_1 = require("./sfProject");
messages_1.Messages.importMessagesDirectory(__dirname);
const messages = messages_1.Messages.load('@salesforce/core', 'auth', [
'invalidRequestUri',
'invalidRequestMethod',
'missingAuthCode',
'serverErrorHTMLResponse',
'portInUse',
'portInUse.actions',
]);
/**
* Handles the creation of a web server for web based login flows.
*
* Usage:
* ```
* const oauthConfig = {
* loginUrl: this.flags.instanceurl,
* clientId: this.flags.clientid,
* };
*
* const oauthServer = await WebOAuthServer.create({ oauthConfig });
* await oauthServer.start();
* await open(oauthServer.getAuthorizationUrl(), { wait: false });
* const authInfo = await oauthServer.authorizeAndSave();
* ```
*/
class WebOAuthServer extends kit_1.AsyncCreatable {
constructor(options) {
super(options);
this.oauthConfig = options.oauthConfig;
}
/**
* Returns the configured oauthLocalPort or the WebOAuthServer.DEFAULT_PORT
*
* @returns {Promise<number>}
*/
static async determineOauthPort() {
try {
const sfProject = await sfProject_1.SfProjectJson.create();
return sfProject.get('oauthLocalPort') || WebOAuthServer.DEFAULT_PORT;
}
catch {
return WebOAuthServer.DEFAULT_PORT;
}
}
/**
* Returns the authorization url that's used for the login flow
*
* @returns {string}
*/
getAuthorizationUrl() {
return this.authUrl;
}
/**
* Executes the oauth request and creates a new AuthInfo when successful
*
* @returns {Promise<AuthInfo>}
*/
async authorizeAndSave() {
if (!this.webServer.server)
await this.start();
return new Promise((resolve, reject) => {
const handler = () => {
this.logger.debug(`OAuth web login service listening on port: ${this.webServer.port}`);
this.executeOauthRequest()
.then(async (response) => {
try {
const authInfo = await org_1.AuthInfo.create({
oauth2Options: this.oauthConfig,
oauth2: this.oauth2,
});
await authInfo.save();
this.webServer.doRedirect(303, authInfo.getOrgFrontDoorUrl(), response);
response.end();
resolve(authInfo);
}
catch (err) {
this.webServer.reportError(err, response);
reject(err);
}
})
.catch((err) => {
this.logger.debug('error reported, closing server connection and re-throwing');
reject(err);
})
.finally(() => {
this.logger.debug('closing server connection');
this.webServer.close();
});
};
// if the server is already listening the listening event won't be fired anymore so execute handler() directly
if (this.webServer.server.listening) {
handler();
}
else {
this.webServer.server.once('listening', handler);
}
});
}
/**
* Starts the web server
*/
async start() {
await this.webServer.start();
}
async init() {
this.logger = await logger_1.Logger.child(this.constructor.name);
const port = await WebOAuthServer.determineOauthPort();
if (!this.oauthConfig.clientId)
this.oauthConfig.clientId = org_1.DEFAULT_CONNECTED_APP_INFO.clientId;
if (!this.oauthConfig.loginUrl)
this.oauthConfig.loginUrl = org_1.AuthInfo.getDefaultInstanceUrl();
if (!this.oauthConfig.redirectUri)
this.oauthConfig.redirectUri = `http://localhost:${port}/OauthRedirect`;
this.webServer = await WebServer.create({ port });
this.oauth2 = new jsforce_1.OAuth2(this.oauthConfig);
this.authUrl = org_1.AuthInfo.getAuthorizationUrl(this.oauthConfig, this.oauth2);
}
/**
* Executes the oauth request
*
* @returns {Promise<AuthInfo>}
*/
async executeOauthRequest() {
return new Promise((resolve, reject) => {
this.logger.debug('Starting web auth flow');
// eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/no-explicit-any
this.webServer.server.on('request', async (request, response) => {
const url = (0, url_1.parse)(request.url);
this.logger.debug(`processing request for uri: ${url.pathname}`);
if (request.method === 'GET') {
if (url.pathname && url.pathname.startsWith('/OauthRedirect')) {
request.query = (0, querystring_1.parse)(url.query);
if (request.query.error) {
const err = new sfError_1.SfError(request.query.error_description || request.query.error, request.query.error);
this.webServer.reportError(err, response);
return reject(err);
}
this.logger.debug(`request.query.state: ${request.query.state}`);
try {
this.oauthConfig.authCode = (0, ts_types_1.asString)(this.parseAuthCodeFromRequest(response, request));
resolve(response);
}
catch (err) {
reject(err);
}
}
else {
this.webServer.sendError(404, 'Resource not found', response);
const errName = 'invalidRequestUri';
const errMessage = messages.getMessage(errName, [url.pathname]);
reject(new sfError_1.SfError(errMessage, errName));
}
}
else {
this.webServer.sendError(405, 'Unsupported http methods', response);
const errName = 'invalidRequestMethod';
const errMessage = messages.getMessage(errName, [request.method]);
reject(new sfError_1.SfError(errMessage, errName));
}
});
});
}
/**
* Parses the auth code from the request url
*
* @param response the http response
* @param request the http request
* @returns {Nullable<string>}
*/
parseAuthCodeFromRequest(response, request) {
if (!this.validateState(request)) {
const error = new sfError_1.SfError('urlStateMismatch');
this.webServer.sendError(400, `${error.message}\n`, response);
this.closeRequest(request);
this.logger.warn('urlStateMismatchAttempt detected.');
if (!(0, ts_types_1.get)(this.webServer.server, 'urlStateMismatchAttempt')) {
this.logger.error(error.message);
(0, kit_1.set)(this.webServer.server, 'urlStateMismatchAttempt', true);
}
}
else {
const authCode = request.query.code;
if (authCode && authCode.length > 4) {
// AuthCodes are generally long strings. For security purposes we will just log the last 4 of the auth code.
this.logger.debug(`Successfully obtained auth code: ...${authCode.substring(authCode.length - 5)}`);
}
else {
this.logger.debug('Expected an auth code but could not find one.');
throw messages.createError('missingAuthCode');
}
this.logger.debug(`oauthConfig.loginUrl: ${this.oauthConfig.loginUrl}`);
this.logger.debug(`oauthConfig.clientId: ${this.oauthConfig.clientId}`);
this.logger.debug(`oauthConfig.redirectUri: ${this.oauthConfig.redirectUri}`);
return authCode;
}
return null;
}
/**
* Closes the request
*
* @param request the http request
*/
closeRequest(request) {
request.connection.end();
request.connection.destroy();
}
/**
* Validates that the state param in the auth url matches the state
* param in the http request
*
* @param request the http request
*/
validateState(request) {
const state = request.query.state;
const query = (0, url_1.parse)(this.authUrl, true).query;
return !!(state && state === query.state);
}
}
exports.WebOAuthServer = WebOAuthServer;
WebOAuthServer.DEFAULT_PORT = 1717;
/**
* Handles the actions specific to the http server
*/
class WebServer extends kit_1.AsyncCreatable {
constructor(options) {
super(options);
this.port = WebOAuthServer.DEFAULT_PORT;
this.host = 'localhost';
this.sockets = [];
if (options.port)
this.port = options.port;
if (options.host)
this.host = options.host;
}
/**
* Starts the http server after checking that the port is open
*/
async start() {
try {
this.logger.debug('Starting web server');
await this.checkOsPort();
this.logger.debug(`Nothing listening on host: localhost port: ${this.port} - good!`);
this.server = http.createServer();
this.server.on('connection', (socket) => {
this.logger.debug(`socket connection initialized from ${socket.remoteAddress}`);
this.sockets.push(socket);
});
this.server.listen(this.port, this.host);
}
catch (err) {
if (err.name === 'EADDRINUSE') {
throw messages.createError('portInUse', [this.port], [this.port]);
}
else {
throw err;
}
}
}
/**
* Closes the http server and all open sockets
*/
close() {
this.sockets.forEach((socket) => {
socket.end();
socket.destroy();
});
this.server.getConnections((_, num) => {
this.logger.debug(`number of connections open: ${num}`);
});
this.server.close();
}
/**
* sends a response error.
*
* @param statusCode he statusCode for the response.
* @param message the message for the http body.
* @param response the response to write the error to.
*/
sendError(status, message, response) {
response.statusMessage = message;
response.statusCode = status;
response.end();
}
/**
* sends a response redirect.
*
* @param statusCode the statusCode for the response.
* @param url the url to redirect to.
* @param response the response to write the redirect to.
*/
doRedirect(status, url, response) {
response.setHeader('Content-Type', 'text/plain');
const body = `${status} - Redirecting to ${url}`;
response.setHeader('Content-Length', Buffer.byteLength(body));
response.writeHead(status, { Location: url });
response.end(body);
}
/**
* sends a response to the browser reporting an error.
*
* @param error the error
* @param response the response to write the redirect to.
*/
reportError(error, response) {
response.setHeader('Content-Type', 'text/html');
const body = messages.getMessage('serverErrorHTMLResponse', [error.message]);
response.setHeader('Content-Length', Buffer.byteLength(body));
response.end(body);
}
async init() {
this.logger = await logger_1.Logger.child(this.constructor.name);
}
/**
* Make sure we can't open a socket on the localhost/host port. It's important because we don't want to send
* auth tokens to a random strange port listener. We want to make sure we can startup our server first.
*
* @private
*/
async checkOsPort() {
return new Promise((resolve, reject) => {
const clientConfig = { port: this.port, host: this.host };
const socket = new net_1.Socket();
socket.setTimeout(this.getSocketTimeout(), () => {
socket.destroy();
const error = new sfError_1.SfError('timeout', 'SOCKET_TIMEOUT');
reject(error);
});
// An existing connection, means that the port is occupied
socket.connect(clientConfig, () => {
socket.destroy();
const error = new sfError_1.SfError('Address in use', 'EADDRINUSE');
error.data = {
port: clientConfig.port,
address: clientConfig.host,
};
reject(error);
});
// An error means that no existing connection exists, which is what we want
socket.on('error', () => {
// eslint-disable-next-line no-console
socket.destroy();
resolve(this.port);
});
});
}
/**
* check and get the socket timeout form what was set in process.env.SFDX_HTTP_SOCKET_TIMEOUT
*
* @returns {number} - represents the socket timeout in ms
* @private
*/
getSocketTimeout() {
const env = new kit_1.Env();
const socketTimeout = (0, kit_1.toNumber)(env.getNumber('SFDX_HTTP_SOCKET_TIMEOUT'));
return Number.isInteger(socketTimeout) && socketTimeout > 0
? socketTimeout
: WebServer.DEFAULT_CLIENT_SOCKET_TIMEOUT;
}
}
exports.WebServer = WebServer;
WebServer.DEFAULT_CLIENT_SOCKET_TIMEOUT = 20000;
//# sourceMappingURL=webOAuthServer.js.map