diff --git a/ghost/mw-session-from-token/.eslintignore b/ghost/mw-session-from-token/.eslintignore new file mode 100644 index 0000000000..6461deecd1 --- /dev/null +++ b/ghost/mw-session-from-token/.eslintignore @@ -0,0 +1 @@ +*.ts diff --git a/ghost/mw-session-from-token/.eslintrc.js b/ghost/mw-session-from-token/.eslintrc.js new file mode 100644 index 0000000000..6a5eab530d --- /dev/null +++ b/ghost/mw-session-from-token/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node', + ] +}; diff --git a/ghost/mw-session-from-token/LICENSE b/ghost/mw-session-from-token/LICENSE new file mode 100644 index 0000000000..a8ebdea81d --- /dev/null +++ b/ghost/mw-session-from-token/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2020 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ghost/mw-session-from-token/README.md b/ghost/mw-session-from-token/README.md new file mode 100644 index 0000000000..efca9c0ffe --- /dev/null +++ b/ghost/mw-session-from-token/README.md @@ -0,0 +1,67 @@ +# Session From Token Middleware + +Middleware to handle generating sessions from tokens, for example like with magic links, or SSO flows similar to SAML. + +## Install + +`npm install @tryghost/mw-session-from-token --save` + +or + +`yarn add @tryghost/mw-session-from-token` + + +## Usage + +```js +const sessionFromTokenMiddleware = require('@tryghost/mw-session-from-token')({ + callNextWithError: true, + async createSession(req, res, user) { + req.session.user_id = user.id; + }, + async getTokenFromRequest(res) { + return req.headers['some-cool-header']; + }, + async getLookupFromToken(token) { + await someTokenService.validate(token); + const data = await someTokenService.getData(token); + return data.email; + }, + async findUserByLookup(lookup) { + return await someUserModel.findOne({email: lookup}); + } +}); + +someExpressApp.get('/some/sso/url', someSessionMiddleware, sessionFromTokenMiddleware, (req, res, next) => { + res.redirect('/loggedin'); +}, (err, res, res, next) => { + res.redirect('/error'); +}); +``` + + +## Develop + +This is a mono repository, managed with [lerna](https://lernajs.io/). + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + + +## Run + +- `yarn dev` + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + + + + +# Copyright & License + +Copyright (c) 2020 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/ghost/mw-session-from-token/index.js b/ghost/mw-session-from-token/index.js new file mode 100644 index 0000000000..2c2e4f5176 --- /dev/null +++ b/ghost/mw-session-from-token/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/SessionFromToken'); diff --git a/ghost/mw-session-from-token/lib/SessionFromToken.js b/ghost/mw-session-from-token/lib/SessionFromToken.js new file mode 100644 index 0000000000..0f721db16d --- /dev/null +++ b/ghost/mw-session-from-token/lib/SessionFromToken.js @@ -0,0 +1,69 @@ +module.exports = SessionFromToken; + +/** + * @typedef {object} User + * @prop {string} id + */ + +/** + * @typedef {import('express').Request} Req + * @typedef {import('express').Response} Res + * @typedef {import('express').NextFunction} Next + * @typedef {import('express').RequestHandler} RequestHandler + */ + +/** + * Returns a connect middleware function which exchanges a token for a session + * + * @template Token + * @template Lookup + * + * @param { object } deps + * @param { (req: Req) => Promise } deps.getTokenFromRequest + * @param { (token: Token) => Promise } deps.getLookupFromToken + * @param { (lookup: Lookup) => Promise } deps.findUserByLookup + * @param { (req: Req, res: Res, user: User) => Promise } deps.createSession + * @param { boolean } deps.callNextWithError - Whether next should be call with an error or just pass through + * + * @returns {RequestHandler} + */ +function SessionFromToken({ + getTokenFromRequest, + getLookupFromToken, + findUserByLookup, + createSession, + callNextWithError +}) { + /** + * @param {Req} req + * @param {Res} res + * @param {Next} next + * @returns {Promise} + */ + async function handler(req, res, next) { + try { + const token = await getTokenFromRequest(req); + if (!token) { + return next(); + } + const email = await getLookupFromToken(token); + if (!email) { + return next(); + } + const user = await findUserByLookup(email); + if (!user) { + return next(); + } + await createSession(req, res, user); + next(); + } catch (err) { + if (callNextWithError) { + next(err); + } else { + next(); + } + } + } + + return handler; +} diff --git a/ghost/mw-session-from-token/package.json b/ghost/mw-session-from-token/package.json new file mode 100644 index 0000000000..85d528f036 --- /dev/null +++ b/ghost/mw-session-from-token/package.json @@ -0,0 +1,33 @@ +{ + "name": "@tryghost/mw-session-from-token", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost-Utils/tree/master/packages/mw-session-from-token", + "author": "Ghost Foundation", + "license": "MIT", + "main": "index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "types": "rm -r types && tsc", + "pretest": "yarn types", + "posttest": "yarn lint" + }, + "files": [ + "index.js", + "lib" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/express": "^4.17.3", + "@types/mocha": "^7.0.2", + "express": "^4.17.1", + "mocha": "7.1.1", + "should": "13.2.3", + "sinon": "9.0.1", + "typescript": "^3.8.3" + }, + "dependencies": {} +} diff --git a/ghost/mw-session-from-token/test/.eslintrc.js b/ghost/mw-session-from-token/test/.eslintrc.js new file mode 100644 index 0000000000..7e76c1a010 --- /dev/null +++ b/ghost/mw-session-from-token/test/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test', + ], + parserOptions: { + ecmaVersion: 2017 + } +}; diff --git a/ghost/mw-session-from-token/test/SessionFromToken.test.js b/ghost/mw-session-from-token/test/SessionFromToken.test.js new file mode 100644 index 0000000000..aa7598323e --- /dev/null +++ b/ghost/mw-session-from-token/test/SessionFromToken.test.js @@ -0,0 +1,44 @@ +const express = require('express'); +const sinon = require('sinon'); +const should = require('should'); +const SessionFromToken = require('../lib/SessionFromToken'); + +describe('SessionFromToken', function () { + it('Parses the request, matches the user to the token, sets the user on req.user and calls createSession', async function () { + const createSession = sinon.spy(async (req, res, user) => { + req.session = user; + }); + const findUserByLookup = sinon.spy(async email => ({id: '1', email})); + const getTokenFromRequest = sinon.spy(async req => req.token); + const getLookupFromToken = sinon.spy(async token => token.email); + + const handler = SessionFromToken({ + getTokenFromRequest, + getLookupFromToken, + findUserByLookup, + createSession, + callNextWithError: true + }); + + const req = Object.create(express.request); + const res = Object.create(express.response); + const next = sinon.spy(); + + req.token = { + email: 'user@host.tld' + }; + + await handler(req, res, next); + + should.ok(getTokenFromRequest.calledOnceWith(req)); + const token = await getTokenFromRequest.returnValues[0]; + + should.ok(getLookupFromToken.calledOnceWith(token)); + const email = await getLookupFromToken.returnValues[0]; + + should.ok(findUserByLookup.calledOnceWith(email)); + const foundUser = await findUserByLookup.returnValues[0]; + + should.ok(createSession.calledOnceWith(req, res, foundUser)); + }); +}); diff --git a/ghost/mw-session-from-token/tsconfig.json b/ghost/mw-session-from-token/tsconfig.json new file mode 100644 index 0000000000..03f41e871e --- /dev/null +++ b/ghost/mw-session-from-token/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "types", + "allowJs": true, + "checkJs": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "es6" + }, + "exclude": [ + "node_modules" + ] +} diff --git a/ghost/mw-session-from-token/types/index.d.ts b/ghost/mw-session-from-token/types/index.d.ts new file mode 100644 index 0000000000..d0d75cd027 --- /dev/null +++ b/ghost/mw-session-from-token/types/index.d.ts @@ -0,0 +1,2 @@ +declare const _exports: typeof import("./lib/SessionFromToken"); +export = _exports; diff --git a/ghost/mw-session-from-token/types/lib/SessionFromToken.d.ts b/ghost/mw-session-from-token/types/lib/SessionFromToken.d.ts new file mode 100644 index 0000000000..ed32ced0ce --- /dev/null +++ b/ghost/mw-session-from-token/types/lib/SessionFromToken.d.ts @@ -0,0 +1,43 @@ +export = SessionFromToken; +/** + * @typedef {object} User + * @prop {string} id + */ +/** + * @typedef {import('express').Request} Req + * @typedef {import('express').Response} Res + * @typedef {import('express').NextFunction} Next + * @typedef {import('express').RequestHandler} RequestHandler + */ +/** + * Returns a connect middleware function which exchanges a token for a session + * + * @template Token + * @template Lookup + * + * @param { object } deps + * @param { (req: Req) => Promise } deps.getTokenFromRequest + * @param { (token: Token) => Promise } deps.getLookupFromToken + * @param { (lookup: Lookup) => Promise } deps.findUserByLookup + * @param { (req: Req, res: Res, user: User) => Promise } deps.createSession + * @param { boolean } deps.callNextWithError - Whether next should be call with an error or just pass through + * + * @returns {RequestHandler} + */ +declare function SessionFromToken({ getTokenFromRequest, getLookupFromToken, findUserByLookup, createSession, callNextWithError }: { + getTokenFromRequest: (req: import("express").Request) => Promise; + getLookupFromToken: (token: Token) => Promise; + findUserByLookup: (lookup: Lookup) => Promise; + createSession: (req: import("express").Request, res: import("express").Response, user: User) => Promise; + callNextWithError: boolean; +}): import("express").RequestHandler; +declare namespace SessionFromToken { + export { User, Req, Res, Next, RequestHandler }; +} +type User = { + id: string; +}; +type Req = import("express").Request; +type Res = import("express").Response; +type Next = import("express").NextFunction; +type RequestHandler = import("express").RequestHandler; diff --git a/ghost/mw-session-from-token/types/test/SessionFromToken.test.d.ts b/ghost/mw-session-from-token/types/test/SessionFromToken.test.d.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/ghost/mw-session-from-token/types/test/SessionFromToken.test.d.ts @@ -0,0 +1 @@ +export {};