UNPKG

@axway/amplify-sdk

Version:

Axway Amplify SDK for Node.js

243 lines (216 loc) 6.49 kB
import crypto from 'crypto'; import errors from './errors.js'; import ejs from 'ejs'; import fs from 'fs-extra'; import getPort from 'get-port'; import http from 'http'; import path from 'path'; import snooplogg from 'snooplogg'; import { fileURLToPath } from 'url'; const { error, log } = snooplogg('amplify-auth:server'); const { green, highlight, red } = snooplogg.styles; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const defaultPort = 3000; const defaultTimeout = 120000; // 2 minutes /** * An HTTP server to listen for redirect callbacks. */ class Server { /** * Initializes the server. * * @param {Object} [opts] - Various options. * @param {Number} [opts.timeout=120000] - The number of milliseconds to wait before timing * out. * @access public */ constructor(opts = {}) { if (!opts || typeof opts !== 'object') { throw new TypeError('Expected options to be an object'); } this.pending = new Map(); this.port = null; this.server = null; this.serverURL = null; this.timeout = opts.timeout || defaultTimeout; } /** * Creates a callback URL. * * @param {Function} [handler] - A response handler for a request callback. * @returns {Promise} * @access public */ async createCallback(handler) { const requestId = crypto.randomBytes(4).toString('hex').toUpperCase(); log(`Creating callback: ${requestId}`); await this.createServer(); return { cancel: async () => { const request = this.pending.get(requestId); if (request) { log(`Cancelling request ${highlight(requestId)}`); clearTimeout(request.timer); this.pending.delete(requestId); await this.stop(); } }, start: () => new Promise((resolve, reject) => { this.pending.set(requestId, { handler, resolve, reject, timer: setTimeout(() => { const request = this.pending.get(requestId); if (request) { log(`Request ${highlight(requestId)} timed out`); this.pending.delete(requestId); request.reject(errors.AUTH_TIMEOUT('Authentication failed: Timed out')); } this.stop(); }, this.timeout) }); }), url: `${this.serverURL}/callback/${requestId}` }; } /** * Creates the server if it's not already created. * * @returns {Promise} * @access private */ async createServer() { if (this.server) { return; } const host = 'localhost'; // this has to be localhost because platform whitelists it const port = this.port = this.port || await getPort({ host, port: defaultPort }); const connections = {}; const callbackRegExp = /^\/callback\/([A-Z0-9]+)/; const serverURL = `http://${host}:${port}`; this.serverURL = serverURL; await new Promise((resolve, reject) => { this.server = http.createServer(async (req, res) => { const url = new URL(req.url, serverURL); let id; let request; log(`Incoming request: ${highlight(url.pathname)}`); try { const m = url.pathname.match(callbackRegExp); if (!m) { throw new Error('Bad Request'); } id = m[1]; request = this.pending.get(id); if (!request) { throw new Error('Invalid Request ID'); } let head = false; const origWriteHead = res.writeHead; res.writeHead = function (status, message, headers) { head = true; log(`${(status >= 400 ? red : green)(String(status))} ${url.pathname} (${id})`); if (status === 302) { const h = headers || (typeof message === 'object' && message); log(`Redirecting client to ${highlight(h?.Location || 'nowhere!')}`); } return origWriteHead.call(res, status, message, headers); }; let end = false; const origEnd = res.end; res.end = function (data, encoding, callback) { end = true; return origEnd.call(res, data, encoding, callback); }; let result; if (typeof request.handler === 'function') { result = await request.handler(req, res, url); } if (!end) { if (head) { // assume no body res.end(); } else { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); } } clearTimeout(request.timer); this.pending.delete(id); request.resolve({ result, url }); } catch (err) { log(`${red(err.status || '400')} ${url.pathname}`); error(err); res.writeHead(err.status || 400, { 'Content-Type': 'text/html' }); const template = path.resolve(__dirname, '../templates/error.html.ejs'); res.end(ejs.render(await fs.readFile(template, 'utf-8'), { title: 'Error', message: err.message })); if (request) { clearTimeout(request.timer); this.pending.delete(id); request.reject(err); } } }); this.server.destroy = async function destroy() { const conns = Object.values(connections); log(`Destroying ${conns.length} connection${conns.length === 1 ? '' : 's'}`); for (const conn of conns) { conn.destroy(); } log('Closing HTTP server...'); await new Promise(resolve => this.close(resolve)); log('HTTP server closed'); }; this.server .on('connection', function (conn) { const key = `${conn.remoteAddress}:${conn.remotePort}`; connections[key] = conn; conn.on('close', () => { delete connections[key]; }); }) .on('error', reject) .on('listening', () => { log(`Local HTTP server started: ${highlight(serverURL)}`); resolve(); }) .listen(port, host); }); } /** * Stops the callback server. * * @param {Boolean} [force] - When `true`, stops the server, drops all connections, and rejects * all pending callbacks. * @returns {Promise} * @memberof Server */ async stop(force) { if (force || this.pending.size === 0) { const { server } = this; if (server) { this.port = null; this.server = null; this.serverURL = null; log('Destroying local HTTP server...'); await server.destroy(); log('Local HTTP server stopped'); } // we need to notify all pending logins that the server was shut down const err = new Error('Server stopped'); for (const [ id, { reject, timer } ] of this.pending.entries()) { log(`Rejecting request ${highlight(id)}`); clearTimeout(timer); reject(err); this.pending.delete(id); } } } } export { Server as default }; //# sourceMappingURL=server.js.map