UNPKG

@salesforce/core

Version:

Core libraries to interact with SFDX projects, orgs, and APIs.

383 lines 14.8 kB
"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