From 6fcd12ddab48360e2687c231f056910a5e0ddd21 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 6 Aug 2020 15:47:35 +0100 Subject: [PATCH 1/6] feat: add settingsParser --- src/utils/__test__/settingsParser.test.js | 115 ++++++++++++++ src/utils/settingsParser.js | 174 ++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 src/utils/__test__/settingsParser.test.js create mode 100644 src/utils/settingsParser.js diff --git a/src/utils/__test__/settingsParser.test.js b/src/utils/__test__/settingsParser.test.js new file mode 100644 index 00000000..ad5b1367 --- /dev/null +++ b/src/utils/__test__/settingsParser.test.js @@ -0,0 +1,115 @@ +import { parseSettings } from "../settingsParser"; + +describe("Settings Parser test", () => { + it("should return two settings", () => { + const dummySetting = [ + { + setting_name: "Setting 1", + setting_type: "String", + setting_key: "setting1", + setting_value: null, + setting_required: false, + }, + { + setting_name: "Setting 2", + setting_type: "Number", + setting_key: "setting2", + setting_value: null, + setting_required: false, + }, + ]; + const settings = parseSettings(dummySetting); + expect(Object.keys(settings).length).toBe(2); + }); + + it("should return two settings and one required error", () => { + const dummySetting = [ + { + setting_name: "Setting 1", + setting_type: "String", + setting_key: "setting1", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Setting 2", + setting_type: "Number", + setting_key: "setting2", + setting_value: 5, + setting_required: true, + }, + ]; + const settings = parseSettings(dummySetting, true); + console.log(settings); + expect(Object.keys(settings).length).toBe(3); + expect(settings.errors.length).toBe(1); + }); + + it("should return two settings, one nested setting and one required error", () => { + const dummySetting = [ + { + setting_name: "Setting 1", + setting_type: "String", + setting_key: "setting1", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Setting 2", + setting_type: "Number", + setting_key: "setting2", + setting_value: 5, + setting_required: true, + }, + { + setting_name: "Setting 3", + setting_type: "Array", + setting_key: "setting3", + setting_required: true, + setting_value: [ + { + setting_name: "Setting 4", + setting_type: "Number", + setting_key: "setting4", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Setting 5", + setting_type: "String", + setting_key: "setting5", + setting_value: 5, + setting_required: true, + }, + ], + }, + ]; + const settings = parseSettings(dummySetting, true); + console.log(settings); + expect(Object.keys(settings.setting3).length).toBe(2); + expect(settings.errors.length).toBe(3); + }); + + it("should return two settings and one type error one required error", () => { + const dummySetting = [ + { + setting_name: "Setting 1", + setting_type: "String", + setting_key: "setting1", + setting_value: 5, + setting_required: true, + }, + { + setting_name: "Setting 2", + setting_type: "Number", + setting_key: "setting2", + setting_value: null, + setting_required: true, + }, + ]; + const settings = parseSettings(dummySetting, true); + console.log(settings); + expect(Object.keys(settings).length).toBe(3); + expect(settings.errors.length).toBe(2); + }); +}); diff --git a/src/utils/settingsParser.js b/src/utils/settingsParser.js new file mode 100644 index 00000000..0b66d9f3 --- /dev/null +++ b/src/utils/settingsParser.js @@ -0,0 +1,174 @@ +//import { mockSettings } from "./mocks/settings"; + +export const settingsSchema = [ + { + setting_name: "Email Verification Callback", + setting_type: "String", + setting_key: "emailVerifyCallback", + setting_required: true, + setting_value: null, + }, + { + setting_name: "Password Reset Callback", + setting_type: "String", + setting_key: "passwordResetCallback", + setting_required: true, + setting_value: null, + }, + { + setting_name: "Login Success Callback", + setting_type: "String", + setting_key: "successCallback", + setting_required: true, + setting_value: null, + }, + { + setting_name: "MongoDB URI", + setting_type: "String", + setting_key: "mongoDbUri", + setting_required: true, + setting_value: null, + }, + { + setting_name: "Facebook Credentials", + setting_type: "Array", + setting_key: "facebookAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Facebook Application ID", + setting_type: "String", + setting_key: "appID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Facebook Application Secret", + setting_type: "String", + setting_key: "appSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Twitter Credentials", + setting_type: "Array", + setting_key: "twitterAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Twitter Consumer Key", + setting_type: "String", + setting_key: "key", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Twitter Consumer Secret", + setting_type: "String", + setting_key: "secret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Github Credentials", + setting_type: "Array", + setting_key: "githubAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Github Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Github Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Google Credentials", + setting_type: "Array", + setting_key: "googleAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Google Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Google Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, +]; + +export const parseSettings = ( + data, + validate = false, + errors = [], + isRecursion = false, + parentKey = "" +) => { + const settings = data.reduce((acc, item) => { + if (item["setting_type"] == "Array") { + const newTemp = parseSettings( + item.setting_value, + item["setting_required"] && validate, //only validate if parent is required + errors, + true, + item["setting_key"] + ); + return { ...acc, [item["setting_key"]]: newTemp }; + } + const temp = acc; + temp[item.setting_key] = item.setting_value; + + //do validation here + if (validate) { + if (item["setting_required"] && !item["setting_value"]) { + errors.push( + `RequiredError: ${parentKey ? parentKey + "." : ""}${ + item["setting_key"] + } is a required setting` + ); + } else if ( + item["setting_type"].toLowerCase() !== + (typeof item["setting_value"]).toLowerCase() + ) { + errors.push( + `TypeError: ${parentKey ? parentKey + "." : ""}${ + item["setting_key"] + } should be a/an ${item["setting_type"]}` + ); + } + } + + return { ...acc, ...temp }; + }, {}); + + //Don't attach errors in recursion + if (validate && !isRecursion && errors.length) { + settings.errors = errors; + } + + return settings; +}; + +// console.log(parseSettings(mockSettings, true)); From 0ea5b729429e734355cffbcd0aaa3ebb1383fdb6 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 6 Aug 2020 15:51:17 +0100 Subject: [PATCH 2/6] chore: fix util functions --- package-lock.json | 29 +++++++++++++++++------ package.json | 7 +++--- src/utils/__test__/settingsParser.test.js | 5 ++-- src/utils/customError.js | 3 ++- src/utils/errorhandler.js | 2 ++ 5 files changed, 32 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index c12fe121..7920663f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4042,6 +4042,22 @@ "resolved": "https://registry.npmjs.org/express-unless/-/express-unless-0.3.1.tgz", "integrity": "sha1-JVfBRudb65A+LSR/m1ugFFJpbiA=" }, + "express-validator": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.6.1.tgz", + "integrity": "sha512-+MrZKJ3eGYXkNF9p9Zf7MS7NkPJFg9MDYATU5c80Cf4F62JdLBIjWxy6481tRC0y1NnC9cgOw8FuN364bWaGhA==", + "requires": { + "lodash": "^4.17.19", + "validator": "^13.1.1" + }, + "dependencies": { + "validator": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.1.1.tgz", + "integrity": "sha512-8GfPiwzzRoWTg7OV1zva1KvrSemuMkv07MA9TTl91hfhe+wKrsrgVN4H2QSFd/U/FhiU3iWPYVgvbsOGwhyFWw==" + } + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -6886,8 +6902,7 @@ "lodash": { "version": "4.17.19", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", - "dev": true + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" }, "lodash.get": { "version": "4.4.2", @@ -9606,11 +9621,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "validator": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", - "integrity": "sha512-jJfE/DW6tIK1Ek8nCfNFqt8Wb3nzMoAbocBF6/Icgg1ZFSBpObdnwVY2jQj6qUqzhx5jc71fpvBWyLGO7Xl+nQ==" - }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -10027,6 +10037,11 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "optional": true + }, + "validator": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", + "integrity": "sha512-jJfE/DW6tIK1Ek8nCfNFqt8Wb3nzMoAbocBF6/Icgg1ZFSBpObdnwVY2jQj6qUqzhx5jc71fpvBWyLGO7Xl+nQ==" } } } diff --git a/package.json b/package.json index 8fc20ccb..56743a75 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "description": "A single-/multi-tenant authentication microservice", "main": "index.js", "scripts": { - "test": "cross-env NODE_ENV=test jest", + "test": "cross-env NODE_ENV=test jest --verbose", "test:watch": "cross-env NODE_ENV=test jest --watch", - "test:cover": "cross-env NODE_ENV=test jest --coverage", - "test:ci": "cross-env NODE_ENV=test jest --coverage && cat ./coverage/lcov.info | coveralls", + "test:cover": "cross-env NODE_ENV=test jest --coverage --verbose", + "test:ci": "cross-env NODE_ENV=test jest --coverage --verbose && cat ./coverage/lcov.info | coveralls", "lint": "eslint \"src/**/*.js\"", "lint:fix": "eslint --fix \"src/**/*.js\"", "build": "babel src --out-dir dist --delete-dir-on-start --ignore '**/*.test.js'", @@ -38,6 +38,7 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "express-jwt": "^5.3.3", + "express-validator": "^6.6.1", "jsonwebtoken": "^8.5.1", "swagger-jsdoc": "^4.0.0", "swagger-ui-express": "^4.1.4" diff --git a/src/utils/__test__/settingsParser.test.js b/src/utils/__test__/settingsParser.test.js index ad5b1367..65ee0c01 100644 --- a/src/utils/__test__/settingsParser.test.js +++ b/src/utils/__test__/settingsParser.test.js @@ -40,7 +40,7 @@ describe("Settings Parser test", () => { }, ]; const settings = parseSettings(dummySetting, true); - console.log(settings); + expect(Object.keys(settings).length).toBe(3); expect(settings.errors.length).toBe(1); }); @@ -85,7 +85,7 @@ describe("Settings Parser test", () => { }, ]; const settings = parseSettings(dummySetting, true); - console.log(settings); + expect(Object.keys(settings.setting3).length).toBe(2); expect(settings.errors.length).toBe(3); }); @@ -108,7 +108,6 @@ describe("Settings Parser test", () => { }, ]; const settings = parseSettings(dummySetting, true); - console.log(settings); expect(Object.keys(settings).length).toBe(3); expect(settings.errors.length).toBe(2); }); diff --git a/src/utils/customError.js b/src/utils/customError.js index c264ef8b..952f96bd 100644 --- a/src/utils/customError.js +++ b/src/utils/customError.js @@ -1,7 +1,8 @@ export default class CustomError extends Error { - constructor(statusCode, message) { + constructor(statusCode, message, errors) { super(); this.statusCode = statusCode; this.message = message; + this.errors = errors; } } diff --git a/src/utils/errorhandler.js b/src/utils/errorhandler.js index fe15ae6d..c1cee0fa 100644 --- a/src/utils/errorhandler.js +++ b/src/utils/errorhandler.js @@ -3,11 +3,13 @@ const errorHandler = (err, req, res) => { res.status(err.statusCode).json({ status: "error", error: err.message, + errors: err.errors, }); } else if (err.status) { res.status(err.status).json({ status: "error", error: err.message, + errors: err.errors, }); } else { res.status(500).json({ From 01de64ccc1f05f3500c60b2dfe3c5da137bd2235 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 7 Aug 2020 05:51:52 +0100 Subject: [PATCH 3/6] feature: add settings middlware --- src/middleware/__test__/settings.test.js | 66 +++++++ src/middleware/settings.js | 67 +++++++ src/utils/mocks/settings.js | 237 +++++++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 src/middleware/__test__/settings.test.js create mode 100644 src/middleware/settings.js create mode 100644 src/utils/mocks/settings.js diff --git a/src/middleware/__test__/settings.test.js b/src/middleware/__test__/settings.test.js new file mode 100644 index 00000000..2970fd85 --- /dev/null +++ b/src/middleware/__test__/settings.test.js @@ -0,0 +1,66 @@ +import express from "express"; +import request from "supertest"; +import errorHandler from "../../utils/errorhandler"; +import jwt from "jsonwebtoken"; +import * as mocks from "../../utils/mocks/settings"; +import settingsMiddleware from "../settings"; + +describe("Settings middleware", () => { + let app; + beforeAll(() => { + process.env.API_SECRET = "secret"; + app = express(); + app.use(settingsMiddleware); + app.get("/", (req, res) => { + res.json({ projectId: req.projectId }); + }); + app.use((err, req, res, next) => { + errorHandler(err, req, res, next); + }); + }); + + it("should throw an error if no API key is present", async () => { + const res = await request(app).get("/"); + expect(res.status).toBe(401); + }); + it("should throw an error if invalid API key is present", async () => { + const res = await request(app) + .get("/") + .set("X-MicroAPI-ProjectKey", "crapKey"); + expect(res.status).toBe(401); + expect(res.body.error).toBe("Invalid API key found"); + }); + + it("should throw invalid settings error", async () => { + const mockSettings = jest + .spyOn(mocks, "mockSettings") + .mockImplementation(() => { + return mocks.errorMockSettings; + }); + + const apiKey = jwt.sign( + { projectId: 123 }, + Buffer.from(process.env.API_SECRET, "base64") + ); + const res = await request(app) + .get("/") + .set("X-MicroAPI-ProjectKey", apiKey); + expect(mockSettings).toHaveBeenCalled(); + expect(res.status).toBe(400); + expect(res.body.error).toBe("Invalid settings found"); + + mockSettings.mockRestore(); + }); + + it("should return status code 200 and decoded projectId", async () => { + const apiKey = jwt.sign( + { projectId: 123 }, + Buffer.from(process.env.API_SECRET, "base64") + ); + const res = await request(app) + .get("/") + .set("X-MicroAPI-ProjectKey", apiKey); + expect(res.status).toBe(200); + expect(res.body.projectId).toBe(123); + }); +}); diff --git a/src/middleware/settings.js b/src/middleware/settings.js new file mode 100644 index 00000000..a7fd9fe8 --- /dev/null +++ b/src/middleware/settings.js @@ -0,0 +1,67 @@ +require("dotenv").config(); +import jwt from "jsonwebtoken"; +import CustomError from "../utils/customError"; +import { parseSettings } from "../utils/settingsParser"; +import { mockSettings as fetchSettings } from "../utils/mocks/settings"; +const log = require("debug")("log"); + +export const getProjectId = (apiKey) => { + //verify/decode projectID from API key + const decoded = jwt.verify( + apiKey, + Buffer.from(process.env.API_SECRET, "base64") + ); + return decoded.projectId; +}; + +//get settings from external DB or endpoint +//function might be modified to accomodate both sources +export const getSettings = async (apiKey) => { + //fool linter + log(apiKey); + + //mock the request for now with mocksettings + //settings need to come from source + //validate the settings by matching against predefined schema + const settings = parseSettings(fetchSettings(), true); + return settings; +}; + +const settingsMiddleware = async (req, res, next) => { + /* In multi-tenant app, projectID is retreived from API key in a custom HTTP header + ** For now we stick with multi-tenant and we will customize this to cater for ** + ** single tenancy architecture in time where projectIDs are irrelevant ** + ** In retrospect, expiry of API keys should be from MicroAPI, ** + ** so making a request for settings with an invalid API key will be rejected ** + */ + try { + // we are calling our custom HTTP header X-MicroAPI-ProjectKey + const apiKey = req.headers["x-microapi-projectkey"]; + if (!apiKey) throw new CustomError(401, "No API key found"); + + //validate apiKey + let projectId; + try { + projectId = getProjectId(apiKey); + } catch (error) { + throw new CustomError(401, "Invalid API key found"); + } + + // get settings from parent DB/source + const settings = await getSettings(apiKey); + if (settings.errors) { + throw new CustomError(400, "Invalid settings found", settings.errors); + } + + //attach setting to request body + req.settings = settings; + req.projectId = projectId; + + //pass to next middleware + next(); + } catch (error) { + next(error); + } +}; + +export default settingsMiddleware; diff --git a/src/utils/mocks/settings.js b/src/utils/mocks/settings.js new file mode 100644 index 00000000..c8bd3941 --- /dev/null +++ b/src/utils/mocks/settings.js @@ -0,0 +1,237 @@ +export const mockSettings = () => [ + { + setting_name: "Email Verification Callback", + setting_type: "String", + setting_key: "emailVerifyCallback", + setting_required: true, + setting_value: "/emailCallback", + }, + { + setting_name: "Password Reset Callback", + setting_type: "String", + setting_key: "passwordResetCallback", + setting_required: true, + setting_value: "/passwordResetCallback", + }, + { + setting_name: "Login Success Callback", + setting_type: "String", + setting_key: "successCallback", + setting_required: true, + setting_value: "successCallback", + }, + { + setting_name: "MongoDB URI", + setting_type: "String", + setting_key: "mongoDbUri", + setting_required: true, + setting_value: "dbUriString", + }, + { + setting_name: "Facebook Credentials", + setting_type: "Array", + setting_key: "facebookAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Facebook Application ID", + setting_type: "String", + setting_key: "appID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Facebook Application Secret", + setting_type: "String", + setting_key: "appSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Twitter Credentials", + setting_type: "Array", + setting_key: "twitterAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Twitter Consumer Key", + setting_type: "String", + setting_key: "key", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Twitter Consumer Secret", + setting_type: "String", + setting_key: "secret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Github Credentials", + setting_type: "Array", + setting_key: "githubAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Github Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Github Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Google Credentials", + setting_type: "Array", + setting_key: "googleAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Google Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Google Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, +]; + +export const errorMockSettings = [ + { + setting_name: "Email Verification Callback", + setting_type: "String", + setting_key: "emailVerifyCallback", + setting_required: true, + setting_value: "/emailCallback", + }, + { + setting_name: "Password Reset Callback", + setting_type: "String", + setting_key: "passwordResetCallback", + setting_required: true, + setting_value: "/passwordResetCallback", + }, + { + setting_name: "Login Success Callback", + setting_type: "String", + setting_key: "successCallback", + setting_required: true, + setting_value: "successCallback", + }, + { + setting_name: "MongoDB URI", + setting_type: "String", + setting_key: "mongoDbUri", + setting_required: true, + setting_value: null, + }, + { + setting_name: "Facebook Credentials", + setting_type: "Array", + setting_key: "facebookAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Facebook Application ID", + setting_type: "String", + setting_key: "appID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Facebook Application Secret", + setting_type: "String", + setting_key: "appSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Twitter Credentials", + setting_type: "Array", + setting_key: "twitterAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Twitter Consumer Key", + setting_type: "String", + setting_key: "key", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Twitter Consumer Secret", + setting_type: "String", + setting_key: "secret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Github Credentials", + setting_type: "Array", + setting_key: "githubAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Github Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Github Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Google Credentials", + setting_type: "Array", + setting_key: "googleAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Google Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Google Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, +]; From 38ac08d8eaef3e69aa6419c26d318ad33a527591 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 6 Aug 2020 15:47:35 +0100 Subject: [PATCH 4/6] feat: add settingsParser --- src/utils/__test__/settingsParser.test.js | 115 ++++++++++++++ src/utils/settingsParser.js | 174 ++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 src/utils/__test__/settingsParser.test.js create mode 100644 src/utils/settingsParser.js diff --git a/src/utils/__test__/settingsParser.test.js b/src/utils/__test__/settingsParser.test.js new file mode 100644 index 00000000..ad5b1367 --- /dev/null +++ b/src/utils/__test__/settingsParser.test.js @@ -0,0 +1,115 @@ +import { parseSettings } from "../settingsParser"; + +describe("Settings Parser test", () => { + it("should return two settings", () => { + const dummySetting = [ + { + setting_name: "Setting 1", + setting_type: "String", + setting_key: "setting1", + setting_value: null, + setting_required: false, + }, + { + setting_name: "Setting 2", + setting_type: "Number", + setting_key: "setting2", + setting_value: null, + setting_required: false, + }, + ]; + const settings = parseSettings(dummySetting); + expect(Object.keys(settings).length).toBe(2); + }); + + it("should return two settings and one required error", () => { + const dummySetting = [ + { + setting_name: "Setting 1", + setting_type: "String", + setting_key: "setting1", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Setting 2", + setting_type: "Number", + setting_key: "setting2", + setting_value: 5, + setting_required: true, + }, + ]; + const settings = parseSettings(dummySetting, true); + console.log(settings); + expect(Object.keys(settings).length).toBe(3); + expect(settings.errors.length).toBe(1); + }); + + it("should return two settings, one nested setting and one required error", () => { + const dummySetting = [ + { + setting_name: "Setting 1", + setting_type: "String", + setting_key: "setting1", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Setting 2", + setting_type: "Number", + setting_key: "setting2", + setting_value: 5, + setting_required: true, + }, + { + setting_name: "Setting 3", + setting_type: "Array", + setting_key: "setting3", + setting_required: true, + setting_value: [ + { + setting_name: "Setting 4", + setting_type: "Number", + setting_key: "setting4", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Setting 5", + setting_type: "String", + setting_key: "setting5", + setting_value: 5, + setting_required: true, + }, + ], + }, + ]; + const settings = parseSettings(dummySetting, true); + console.log(settings); + expect(Object.keys(settings.setting3).length).toBe(2); + expect(settings.errors.length).toBe(3); + }); + + it("should return two settings and one type error one required error", () => { + const dummySetting = [ + { + setting_name: "Setting 1", + setting_type: "String", + setting_key: "setting1", + setting_value: 5, + setting_required: true, + }, + { + setting_name: "Setting 2", + setting_type: "Number", + setting_key: "setting2", + setting_value: null, + setting_required: true, + }, + ]; + const settings = parseSettings(dummySetting, true); + console.log(settings); + expect(Object.keys(settings).length).toBe(3); + expect(settings.errors.length).toBe(2); + }); +}); diff --git a/src/utils/settingsParser.js b/src/utils/settingsParser.js new file mode 100644 index 00000000..0b66d9f3 --- /dev/null +++ b/src/utils/settingsParser.js @@ -0,0 +1,174 @@ +//import { mockSettings } from "./mocks/settings"; + +export const settingsSchema = [ + { + setting_name: "Email Verification Callback", + setting_type: "String", + setting_key: "emailVerifyCallback", + setting_required: true, + setting_value: null, + }, + { + setting_name: "Password Reset Callback", + setting_type: "String", + setting_key: "passwordResetCallback", + setting_required: true, + setting_value: null, + }, + { + setting_name: "Login Success Callback", + setting_type: "String", + setting_key: "successCallback", + setting_required: true, + setting_value: null, + }, + { + setting_name: "MongoDB URI", + setting_type: "String", + setting_key: "mongoDbUri", + setting_required: true, + setting_value: null, + }, + { + setting_name: "Facebook Credentials", + setting_type: "Array", + setting_key: "facebookAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Facebook Application ID", + setting_type: "String", + setting_key: "appID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Facebook Application Secret", + setting_type: "String", + setting_key: "appSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Twitter Credentials", + setting_type: "Array", + setting_key: "twitterAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Twitter Consumer Key", + setting_type: "String", + setting_key: "key", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Twitter Consumer Secret", + setting_type: "String", + setting_key: "secret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Github Credentials", + setting_type: "Array", + setting_key: "githubAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Github Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Github Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Google Credentials", + setting_type: "Array", + setting_key: "googleAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Google Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Google Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, +]; + +export const parseSettings = ( + data, + validate = false, + errors = [], + isRecursion = false, + parentKey = "" +) => { + const settings = data.reduce((acc, item) => { + if (item["setting_type"] == "Array") { + const newTemp = parseSettings( + item.setting_value, + item["setting_required"] && validate, //only validate if parent is required + errors, + true, + item["setting_key"] + ); + return { ...acc, [item["setting_key"]]: newTemp }; + } + const temp = acc; + temp[item.setting_key] = item.setting_value; + + //do validation here + if (validate) { + if (item["setting_required"] && !item["setting_value"]) { + errors.push( + `RequiredError: ${parentKey ? parentKey + "." : ""}${ + item["setting_key"] + } is a required setting` + ); + } else if ( + item["setting_type"].toLowerCase() !== + (typeof item["setting_value"]).toLowerCase() + ) { + errors.push( + `TypeError: ${parentKey ? parentKey + "." : ""}${ + item["setting_key"] + } should be a/an ${item["setting_type"]}` + ); + } + } + + return { ...acc, ...temp }; + }, {}); + + //Don't attach errors in recursion + if (validate && !isRecursion && errors.length) { + settings.errors = errors; + } + + return settings; +}; + +// console.log(parseSettings(mockSettings, true)); From 51016edbd1d01fa4dde970bbbc45868725d6dfeb Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 6 Aug 2020 15:51:17 +0100 Subject: [PATCH 5/6] chore: fix util functions --- package-lock.json | 29 +++++++++++++++++------ package.json | 9 +++---- src/utils/__test__/settingsParser.test.js | 5 ++-- src/utils/customError.js | 3 ++- src/utils/errorhandler.js | 2 ++ 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c52112a..4b53bfb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4059,6 +4059,22 @@ "resolved": "https://registry.npmjs.org/express-unless/-/express-unless-0.3.1.tgz", "integrity": "sha1-JVfBRudb65A+LSR/m1ugFFJpbiA=" }, + "express-validator": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.6.1.tgz", + "integrity": "sha512-+MrZKJ3eGYXkNF9p9Zf7MS7NkPJFg9MDYATU5c80Cf4F62JdLBIjWxy6481tRC0y1NnC9cgOw8FuN364bWaGhA==", + "requires": { + "lodash": "^4.17.19", + "validator": "^13.1.1" + }, + "dependencies": { + "validator": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.1.1.tgz", + "integrity": "sha512-8GfPiwzzRoWTg7OV1zva1KvrSemuMkv07MA9TTl91hfhe+wKrsrgVN4H2QSFd/U/FhiU3iWPYVgvbsOGwhyFWw==" + } + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -6895,8 +6911,7 @@ "lodash": { "version": "4.17.19", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", - "dev": true + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" }, "lodash.get": { "version": "4.4.2", @@ -9793,11 +9808,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "validator": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", - "integrity": "sha512-jJfE/DW6tIK1Ek8nCfNFqt8Wb3nzMoAbocBF6/Icgg1ZFSBpObdnwVY2jQj6qUqzhx5jc71fpvBWyLGO7Xl+nQ==" - }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -10214,6 +10224,11 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "optional": true + }, + "validator": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", + "integrity": "sha512-jJfE/DW6tIK1Ek8nCfNFqt8Wb3nzMoAbocBF6/Icgg1ZFSBpObdnwVY2jQj6qUqzhx5jc71fpvBWyLGO7Xl+nQ==" } } } diff --git a/package.json b/package.json index b1e8f79e..8817e734 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "description": "A single-/multi-tenant authentication microservice", "main": "index.js", "scripts": { - "test": "cross-env NODE_ENV=test jest", - "test:watch": "cross-env NODE_ENV=test jest --watch", - "test:cover": "cross-env NODE_ENV=test jest --coverage", - "test:ci": "cross-env NODE_ENV=test jest --coverage && shx cat ./coverage/lcov.info", + "test": "cross-env NODE_ENV=test jest --verbose", + "test:watch": "cross-env NODE_ENV=test jest --watch --verbose", + "test:ci": "cross-env NODE_ENV=test jest --coverage --verbose && shx cat ./coverage/lcov.info", + "test:cover": "cross-env NODE_ENV=test jest --coverage --verbose", "lint": "eslint \"src/**/*.js\"", "lint:fix": "eslint --fix \"src/**/*.js\"", "build": "babel src --out-dir dist --delete-dir-on-start --ignore '**/*.test.js'", @@ -38,6 +38,7 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "express-jwt": "^6.0.0", + "express-validator": "^6.6.1", "jsonwebtoken": "^8.5.1", "mongoose": "^5.9.27", "swagger-jsdoc": "^4.0.0", diff --git a/src/utils/__test__/settingsParser.test.js b/src/utils/__test__/settingsParser.test.js index ad5b1367..65ee0c01 100644 --- a/src/utils/__test__/settingsParser.test.js +++ b/src/utils/__test__/settingsParser.test.js @@ -40,7 +40,7 @@ describe("Settings Parser test", () => { }, ]; const settings = parseSettings(dummySetting, true); - console.log(settings); + expect(Object.keys(settings).length).toBe(3); expect(settings.errors.length).toBe(1); }); @@ -85,7 +85,7 @@ describe("Settings Parser test", () => { }, ]; const settings = parseSettings(dummySetting, true); - console.log(settings); + expect(Object.keys(settings.setting3).length).toBe(2); expect(settings.errors.length).toBe(3); }); @@ -108,7 +108,6 @@ describe("Settings Parser test", () => { }, ]; const settings = parseSettings(dummySetting, true); - console.log(settings); expect(Object.keys(settings).length).toBe(3); expect(settings.errors.length).toBe(2); }); diff --git a/src/utils/customError.js b/src/utils/customError.js index c264ef8b..952f96bd 100644 --- a/src/utils/customError.js +++ b/src/utils/customError.js @@ -1,7 +1,8 @@ export default class CustomError extends Error { - constructor(statusCode, message) { + constructor(statusCode, message, errors) { super(); this.statusCode = statusCode; this.message = message; + this.errors = errors; } } diff --git a/src/utils/errorhandler.js b/src/utils/errorhandler.js index fe15ae6d..c1cee0fa 100644 --- a/src/utils/errorhandler.js +++ b/src/utils/errorhandler.js @@ -3,11 +3,13 @@ const errorHandler = (err, req, res) => { res.status(err.statusCode).json({ status: "error", error: err.message, + errors: err.errors, }); } else if (err.status) { res.status(err.status).json({ status: "error", error: err.message, + errors: err.errors, }); } else { res.status(500).json({ From ca4014212ee78420413c7dce2e64a0f9927d9c27 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 7 Aug 2020 05:51:52 +0100 Subject: [PATCH 6/6] feature: add settings middlware --- src/middleware/__test__/settings.test.js | 66 +++++++ src/middleware/settings.js | 67 +++++++ src/utils/mocks/settings.js | 237 +++++++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 src/middleware/__test__/settings.test.js create mode 100644 src/middleware/settings.js create mode 100644 src/utils/mocks/settings.js diff --git a/src/middleware/__test__/settings.test.js b/src/middleware/__test__/settings.test.js new file mode 100644 index 00000000..2970fd85 --- /dev/null +++ b/src/middleware/__test__/settings.test.js @@ -0,0 +1,66 @@ +import express from "express"; +import request from "supertest"; +import errorHandler from "../../utils/errorhandler"; +import jwt from "jsonwebtoken"; +import * as mocks from "../../utils/mocks/settings"; +import settingsMiddleware from "../settings"; + +describe("Settings middleware", () => { + let app; + beforeAll(() => { + process.env.API_SECRET = "secret"; + app = express(); + app.use(settingsMiddleware); + app.get("/", (req, res) => { + res.json({ projectId: req.projectId }); + }); + app.use((err, req, res, next) => { + errorHandler(err, req, res, next); + }); + }); + + it("should throw an error if no API key is present", async () => { + const res = await request(app).get("/"); + expect(res.status).toBe(401); + }); + it("should throw an error if invalid API key is present", async () => { + const res = await request(app) + .get("/") + .set("X-MicroAPI-ProjectKey", "crapKey"); + expect(res.status).toBe(401); + expect(res.body.error).toBe("Invalid API key found"); + }); + + it("should throw invalid settings error", async () => { + const mockSettings = jest + .spyOn(mocks, "mockSettings") + .mockImplementation(() => { + return mocks.errorMockSettings; + }); + + const apiKey = jwt.sign( + { projectId: 123 }, + Buffer.from(process.env.API_SECRET, "base64") + ); + const res = await request(app) + .get("/") + .set("X-MicroAPI-ProjectKey", apiKey); + expect(mockSettings).toHaveBeenCalled(); + expect(res.status).toBe(400); + expect(res.body.error).toBe("Invalid settings found"); + + mockSettings.mockRestore(); + }); + + it("should return status code 200 and decoded projectId", async () => { + const apiKey = jwt.sign( + { projectId: 123 }, + Buffer.from(process.env.API_SECRET, "base64") + ); + const res = await request(app) + .get("/") + .set("X-MicroAPI-ProjectKey", apiKey); + expect(res.status).toBe(200); + expect(res.body.projectId).toBe(123); + }); +}); diff --git a/src/middleware/settings.js b/src/middleware/settings.js new file mode 100644 index 00000000..a7fd9fe8 --- /dev/null +++ b/src/middleware/settings.js @@ -0,0 +1,67 @@ +require("dotenv").config(); +import jwt from "jsonwebtoken"; +import CustomError from "../utils/customError"; +import { parseSettings } from "../utils/settingsParser"; +import { mockSettings as fetchSettings } from "../utils/mocks/settings"; +const log = require("debug")("log"); + +export const getProjectId = (apiKey) => { + //verify/decode projectID from API key + const decoded = jwt.verify( + apiKey, + Buffer.from(process.env.API_SECRET, "base64") + ); + return decoded.projectId; +}; + +//get settings from external DB or endpoint +//function might be modified to accomodate both sources +export const getSettings = async (apiKey) => { + //fool linter + log(apiKey); + + //mock the request for now with mocksettings + //settings need to come from source + //validate the settings by matching against predefined schema + const settings = parseSettings(fetchSettings(), true); + return settings; +}; + +const settingsMiddleware = async (req, res, next) => { + /* In multi-tenant app, projectID is retreived from API key in a custom HTTP header + ** For now we stick with multi-tenant and we will customize this to cater for ** + ** single tenancy architecture in time where projectIDs are irrelevant ** + ** In retrospect, expiry of API keys should be from MicroAPI, ** + ** so making a request for settings with an invalid API key will be rejected ** + */ + try { + // we are calling our custom HTTP header X-MicroAPI-ProjectKey + const apiKey = req.headers["x-microapi-projectkey"]; + if (!apiKey) throw new CustomError(401, "No API key found"); + + //validate apiKey + let projectId; + try { + projectId = getProjectId(apiKey); + } catch (error) { + throw new CustomError(401, "Invalid API key found"); + } + + // get settings from parent DB/source + const settings = await getSettings(apiKey); + if (settings.errors) { + throw new CustomError(400, "Invalid settings found", settings.errors); + } + + //attach setting to request body + req.settings = settings; + req.projectId = projectId; + + //pass to next middleware + next(); + } catch (error) { + next(error); + } +}; + +export default settingsMiddleware; diff --git a/src/utils/mocks/settings.js b/src/utils/mocks/settings.js new file mode 100644 index 00000000..c8bd3941 --- /dev/null +++ b/src/utils/mocks/settings.js @@ -0,0 +1,237 @@ +export const mockSettings = () => [ + { + setting_name: "Email Verification Callback", + setting_type: "String", + setting_key: "emailVerifyCallback", + setting_required: true, + setting_value: "/emailCallback", + }, + { + setting_name: "Password Reset Callback", + setting_type: "String", + setting_key: "passwordResetCallback", + setting_required: true, + setting_value: "/passwordResetCallback", + }, + { + setting_name: "Login Success Callback", + setting_type: "String", + setting_key: "successCallback", + setting_required: true, + setting_value: "successCallback", + }, + { + setting_name: "MongoDB URI", + setting_type: "String", + setting_key: "mongoDbUri", + setting_required: true, + setting_value: "dbUriString", + }, + { + setting_name: "Facebook Credentials", + setting_type: "Array", + setting_key: "facebookAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Facebook Application ID", + setting_type: "String", + setting_key: "appID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Facebook Application Secret", + setting_type: "String", + setting_key: "appSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Twitter Credentials", + setting_type: "Array", + setting_key: "twitterAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Twitter Consumer Key", + setting_type: "String", + setting_key: "key", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Twitter Consumer Secret", + setting_type: "String", + setting_key: "secret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Github Credentials", + setting_type: "Array", + setting_key: "githubAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Github Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Github Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Google Credentials", + setting_type: "Array", + setting_key: "googleAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Google Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Google Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, +]; + +export const errorMockSettings = [ + { + setting_name: "Email Verification Callback", + setting_type: "String", + setting_key: "emailVerifyCallback", + setting_required: true, + setting_value: "/emailCallback", + }, + { + setting_name: "Password Reset Callback", + setting_type: "String", + setting_key: "passwordResetCallback", + setting_required: true, + setting_value: "/passwordResetCallback", + }, + { + setting_name: "Login Success Callback", + setting_type: "String", + setting_key: "successCallback", + setting_required: true, + setting_value: "successCallback", + }, + { + setting_name: "MongoDB URI", + setting_type: "String", + setting_key: "mongoDbUri", + setting_required: true, + setting_value: null, + }, + { + setting_name: "Facebook Credentials", + setting_type: "Array", + setting_key: "facebookAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Facebook Application ID", + setting_type: "String", + setting_key: "appID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Facebook Application Secret", + setting_type: "String", + setting_key: "appSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Twitter Credentials", + setting_type: "Array", + setting_key: "twitterAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Twitter Consumer Key", + setting_type: "String", + setting_key: "key", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Twitter Consumer Secret", + setting_type: "String", + setting_key: "secret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Github Credentials", + setting_type: "Array", + setting_key: "githubAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Github Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Github Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, + { + setting_name: "Google Credentials", + setting_type: "Array", + setting_key: "googleAuthProvider", + setting_required: false, + setting_value: [ + { + setting_name: "Google Client ID", + setting_type: "String", + setting_key: "clientID", + setting_value: null, + setting_required: true, + }, + { + setting_name: "Google Client Secret", + setting_type: "String", + setting_key: "clientSecret", + setting_value: null, + setting_required: true, + }, + ], + }, +];