├── Procfile ├── .travis.yml ├── test ├── mocha.opts ├── .eslintrc.json ├── index.js ├── unit │ ├── util │ │ ├── template.test.js │ │ ├── middleware.test.js │ │ └── hash.test.js │ ├── framework │ │ ├── validate.test.js │ │ ├── dispatch.test.js │ │ └── typedef.test.js │ ├── services │ │ └── token.test.js │ └── prelude.test.js └── integration │ └── server.test.js ├── config ├── test.js ├── development.js ├── production.js └── default.js ├── src ├── util │ ├── log.js │ ├── middleware.js │ ├── request.js │ ├── route.js │ ├── permission.js │ ├── validate.js │ ├── service.js │ ├── typedef.js │ ├── template.js │ ├── version.js │ ├── dispatch.js │ └── hash.js ├── actions │ ├── auth │ │ ├── index.js │ │ ├── create.js │ │ ├── update.js │ │ └── session.js │ └── index.js ├── bootstrap │ ├── service.js │ ├── token.js │ ├── users.js │ ├── sigint.js │ ├── config.js │ ├── auth.js │ ├── app.js │ └── http.js ├── domain │ ├── models.js │ └── types.js ├── routes │ ├── app.js │ ├── preconditions.js │ ├── error.js │ └── common.js ├── services │ ├── token.js │ └── auth.js ├── env.js └── prelude.js ├── .editorconfig ├── .eslintrc.json ├── ecosystem.config.js ├── README.md ├── .gitignore ├── index.js ├── package.json └── docs └── api.md /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run pm2 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5.0.0" 4 | - "5" 5 | - "6" 6 | - "7" 7 | - "8" 8 | after_success: npm run coverage:codecov 9 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --check-leaks 2 | --full-trace 3 | --globals expect,sinon 4 | --no-exit 5 | --reporter list 6 | --require test/index.js 7 | --ui bdd 8 | --recursive 9 | -------------------------------------------------------------------------------- /config/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | security: { 5 | secret: 'test' 6 | }, 7 | log: { 8 | level: 'silent' 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/util/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Logger} = require('winston'); 4 | const config = require('config'); 5 | 6 | const log = config.get('log'); 7 | 8 | module.exports = new Logger(log); 9 | -------------------------------------------------------------------------------- /src/util/middleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Future = require('fluture'); 4 | const {Middleware} = require('momi'); 5 | 6 | exports.race = (ma, mb) => Middleware(s => Future.race(ma.run(s), mb.run(s))); 7 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-unused-expressions": 0, 4 | "no-invalid-this": 0 5 | }, 6 | "env": { 7 | "mocha": true 8 | }, 9 | "globals": { 10 | "expect": true, 11 | "sinon": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /config/development.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | server: { 5 | cors: ['http://localhost:4000', 'http://127.0.0.1:4000'], 6 | }, 7 | log: { 8 | level: 'debug' 9 | }, 10 | security: { 11 | secret: 'such secret' 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/util/request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request'); 4 | const Future = require('fluture'); 5 | 6 | module.exports = o => Future((l, r) => { 7 | const socket = request(o, (err, res) => err ? l(err) : r(res)); 8 | return () => socket.abort(); 9 | }); 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_size = 2 13 | 14 | -------------------------------------------------------------------------------- /config/production.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {transports} = require('winston'); 4 | 5 | module.exports = { 6 | log: { 7 | level: 'info', 8 | transports: [ 9 | new transports.Console({ 10 | align: true, 11 | colorize: false, 12 | timestamp: true 13 | }) 14 | ] 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["warp/node", "warp/es6"], 3 | "parserOptions": { 4 | "sourceType": "script" 5 | }, 6 | "rules": { 7 | "newline-before-return": 0, 8 | "strict": [2, "global"], 9 | "prefer-rest-params": 0, 10 | "indent": [2, 2, { 11 | "MemberExpression": 0, 12 | "SwitchCase": 1 13 | }] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/util/route.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Router} = require('express'); 4 | const log = require('./log'); 5 | const {curry2} = require('../prelude'); 6 | 7 | module.exports = curry2((server, file) => { 8 | const router = new Router(); 9 | log.debug(`Mounting routes: ${file}`); 10 | require(`../routes/${file}`)(router); 11 | server.use(router); 12 | }); 13 | -------------------------------------------------------------------------------- /src/actions/auth/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Future = require('fluture'); 4 | const {either} = require('../../prelude'); 5 | const serialize = require('serialize-http-error'); 6 | 7 | module.exports = req => Future.of(either( 8 | error => ({authenticated: false, reason: serialize(error)}), 9 | session => ({authenticated: true, session}), 10 | req.auth.session 11 | )); 12 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.NODE_ENV = 'test'; 4 | 5 | const chai = require('chai'); 6 | const sinon = require('sinon'); 7 | const sinonChai = require('sinon-chai'); 8 | const chaiThings = require('chai-things'); 9 | 10 | //Configure chai. 11 | chai.use(sinonChai); 12 | chai.use(chaiThings); 13 | 14 | //Expose globals. 15 | global.expect = chai.expect; 16 | global.sinon = sinon; 17 | -------------------------------------------------------------------------------- /src/bootstrap/service.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Middleware} = require('momi'); 4 | const {K} = require('../prelude'); 5 | 6 | // Services :: StrMap String Any 7 | const Services = () => ({}); 8 | 9 | //This bootstrapper prepares the state to be used by the functions in ./util. 10 | // default :: Middleware a b c -> Middleware {services: Services} b c 11 | module.exports = next => Middleware.put({services: Services()}).chain(K(next)); 12 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {main, name} = require('./package.json'); 4 | 5 | /* eslint-disable camelcase */ 6 | exports.apps = [ 7 | { 8 | name: name, 9 | script: main, 10 | merge_logs: true, 11 | log_date_format: 'YYYY-MM-DD HH:mm:ss,SSSS', 12 | exec_mode: 'cluster', 13 | node_args: '--optimize_for_size --max_old_space_size=920 --gc_interval=100', 14 | instances: 0, 15 | wait_ready: true 16 | } 17 | ]; 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Not Maintained 2 | 3 | This project is no longer maintained. Most if its useful bits have been 4 | extracted to several packages. 5 | 6 | * `util/dispatch`: [`fluture-express`](https://github.com/fluture-js/fluture-express) 7 | * `util/version`: [`route-v`](https://github.com/Amri91/route-v) 8 | * `util/permission`: [`permissionary`](https://github.com/Avaq/permissionary) 9 | * `services/auth`: [`authomatic`](https://github.com/wearereasonablepeople/authomatic) 10 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const meta = require('../../package'); 4 | const Future = require('fluture'); 5 | 6 | module.exports = req => Future.of({ 7 | name: meta.name, 8 | version: meta.version, 9 | machine: process.env.HOSTNAME || process.env.HOST || '', 10 | uptime: process.uptime(), 11 | user: process.env.USERNAME || process.env.USER || '', 12 | request: req.name, 13 | ip: req.ip, 14 | env: process.env.NODE_ENV 15 | }); 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.orig 5 | *.log 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *~ 11 | 12 | # OS or Editor files 13 | .DS_Store 14 | .cache 15 | .project 16 | .settings 17 | .tmproj 18 | nbproject 19 | Thumbs.db 20 | .idea/ 21 | 22 | # NPM packages folder 23 | node_modules/ 24 | 25 | # Code coverage report 26 | coverage/ 27 | .nyc_output/ 28 | 29 | # Test files 30 | test.*.js 31 | 32 | # Environment 33 | /config/local* 34 | 35 | -------------------------------------------------------------------------------- /src/bootstrap/token.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createTokenService = require('../services/token'); 4 | const {App, Middleware} = require('momi'); 5 | const {putService, getService} = require('../util/service'); 6 | const {map, T} = require('../prelude'); 7 | 8 | module.exports = App.do(function*(next) { 9 | const secret = yield getService('config').chain(map(Middleware.lift, T('security.secret'))); 10 | yield putService('token', createTokenService(secret)); 11 | return yield next; 12 | }); 13 | -------------------------------------------------------------------------------- /src/util/permission.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const error = require('http-errors'); 4 | const log = require('./log'); 5 | const {I, either} = require('../prelude'); 6 | 7 | const missingPermission = error(403, `You are not authorized`); 8 | 9 | module.exports = required => (req, res, next) => 10 | req.auth.has(required) 11 | ? next() 12 | : next(either(I, sess => { 13 | log.debug(`User "${sess.user}" is missing the "${required}"-permission`); 14 | return missingPermission; 15 | }, req.auth.session)); 16 | -------------------------------------------------------------------------------- /src/util/validate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {validate: validate_} = require('tcomb-validation'); 4 | const createError = require('http-errors'); 5 | const Future = require('fluture'); 6 | const {curry2} = require('../prelude'); 7 | 8 | //validate :: Type -> a -> Future[BadRequestError, a] 9 | module.exports = curry2((Type, a) => Future((rej, res) => { 10 | const validation = validate_(a, Type); 11 | const err = validation.firstError(); 12 | validation.isValid() ? res(a) : rej(createError(400, err.message)); 13 | })); 14 | -------------------------------------------------------------------------------- /src/domain/models.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {inter, list} = require('tcomb'); 4 | const T = require('./types'); 5 | 6 | exports.Authentication = inter({ 7 | username: T.Username, 8 | password: T.Password 9 | }, 'Authentication'); 10 | 11 | exports.Authorization = inter({ 12 | token: T.String, 13 | refresh: T.String 14 | }, 'Authorization'); 15 | 16 | exports.User = inter({ 17 | username: T.Username, 18 | password: T.Password, 19 | groups: list(T.Group) 20 | }, 'User'); 21 | 22 | exports.Session = inter({ 23 | user: T.Username, 24 | groups: list(T.Group) 25 | }, 'Session'); 26 | -------------------------------------------------------------------------------- /src/routes/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const dispatch = require('../util/dispatch'); 4 | const permission = require('../util/permission'); 5 | const {json} = require('body-parser'); 6 | 7 | module.exports = router => { 8 | 9 | router.use(json({limit: '2mb'})); 10 | router.use(dispatch('auth/session')); 11 | 12 | router.get('/', permission('ping'), dispatch('index')); 13 | router.get('/auth', permission('auth.view'), dispatch('auth/index')); 14 | router.post('/auth', permission('auth.create'), dispatch('auth/create')); 15 | router.put('/auth', permission('auth.update'), dispatch('auth/update')); 16 | 17 | }; 18 | -------------------------------------------------------------------------------- /src/bootstrap/users.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Future = require('fluture'); 4 | const bcrypt = require('bcrypt'); 5 | const {User} = require('../domain/models'); 6 | const {putService} = require('../util/service'); 7 | const {K, Just} = require('../prelude'); 8 | 9 | const getUserByUsername = username => 10 | Future.node(done => bcrypt.hash('password123', 10, done)) 11 | .map(password => ({username, password, groups: []})) 12 | .map(User) 13 | .map(Just); 14 | 15 | //TODO: This attaches a mock service which loads a user by username. It must be replaced. 16 | module.exports = next => putService('users', {get: getUserByUsername}).chain(K(next)); 17 | -------------------------------------------------------------------------------- /src/util/service.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Middleware} = require('momi'); 4 | const {concat} = require('../prelude'); 5 | 6 | // putService :: String -> Any -> Middleware {services: Services} b () 7 | exports.putService = (x, service) => Middleware.modify(state => concat(state, { 8 | services: concat(state.services, {[x]: service}) 9 | })); 10 | 11 | // getService :: String -> Middleware {services: Services} b Any 12 | exports.getService = x => Middleware.get.map(state => { 13 | const service = state.services[x]; 14 | if(!service) { 15 | throw new Error(`The ${x} service has not been registerred`); 16 | } 17 | return service; 18 | }); 19 | -------------------------------------------------------------------------------- /src/bootstrap/sigint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Middleware} = require('momi'); 4 | const Future = require('fluture'); 5 | const log = require('../util/log'); 6 | const {K} = require('../prelude'); 7 | 8 | //This bootstrapper keeps the process alive until a SIGINT is received. 9 | // default :: a -> Middleware a b () 10 | module.exports = K(Middleware.lift(Future((rej, res) => { 11 | typeof process.send === 'function' && process.send('ready'); 12 | log.info('Ready for take-off\n'); 13 | process.once('SIGINT', _ => { 14 | process.removeAllListeners('uncaughtException'); 15 | log.info('Starting exit procedure...'); 16 | res(1); 17 | }); 18 | }))); 19 | -------------------------------------------------------------------------------- /src/util/typedef.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const t = require('tcomb'); 4 | const {T, pipe, pairs, filter, snd, fst, map, anyPass, values, complement} = require('../prelude'); 5 | 6 | const getErrors = pipe([pairs, filter(snd), map(fst)]); 7 | 8 | module.exports = (name, Supertype, validations) => { 9 | 10 | const Type = t.refinement(Supertype, complement(anyPass(values(validations))), name); 11 | 12 | Type.getValidationErrorMessage = (v, path) => { 13 | const errors = getErrors(map(T(v), validations)); 14 | return errors.length === 0 15 | ? undefined 16 | : `The ${path && path.length > 0 ? `'${path.join('.')}'` : name} ${errors.join(', and; ')}`; 17 | }; 18 | 19 | return Type; 20 | 21 | }; 22 | -------------------------------------------------------------------------------- /test/unit/util/template.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('../../../src/util/template'); 4 | 5 | describe('Templating functions', () => { 6 | 7 | describe('.line()', () => { 8 | 9 | it('should apply the proper string conversions', () => { 10 | 11 | const tests = { 12 | 'foo bar baz': util.line ` 13 | foo 14 | bar 15 | baz 16 | `, 17 | 'nyerk snarl': util.line ` 18 | nyerk 19 | 20 | 21 | snarl` 22 | }; 23 | 24 | Object.keys(tests).forEach(expected => { 25 | const actual = tests[expected]; 26 | expect(actual).to.equal(expected); 27 | }); 28 | 29 | }); 30 | 31 | }); 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('./src/util/log'); 4 | const {App} = require('momi'); 5 | 6 | const app = App.empty() 7 | .use(require('./src/bootstrap/service')) 8 | .use(require('./src/bootstrap/config')) 9 | .use(require('./src/bootstrap/token')) 10 | .use(require('./src/bootstrap/users')) 11 | .use(require('./src/bootstrap/auth')) 12 | .use(require('./src/bootstrap/app')) 13 | .use(require('./src/bootstrap/http')) 14 | .use(require('./src/bootstrap/sigint')); 15 | 16 | App.run(app, null).fork( 17 | err => { 18 | log.error(err && err.stack || err); 19 | process.exit(1); 20 | }, 21 | _ => { 22 | process.removeAllListeners(); 23 | log.info('We hope you had a pleasant flight'); 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /src/bootstrap/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('config'); 4 | const {putService} = require('../util/service'); 5 | const Future = require('fluture'); 6 | const {App, Middleware} = require('momi'); 7 | const log = require('../util/log'); 8 | 9 | // loadConfig :: String -> Future Error Any 10 | const loadConfig = prop => Future.try(_ => config.get(prop)); 11 | 12 | //This bootstrapper registers a config service which loads application settings. 13 | module.exports = App.do(function*(next) { 14 | 15 | if(!process.env.NODE_ENV) { 16 | yield Middleware.lift(Future.reject(new Error('NODE_ENV is not set'))); 17 | } 18 | 19 | log.info(`Using "${process.env.NODE_ENV}" configurations`); 20 | 21 | yield putService('config', loadConfig); 22 | 23 | return yield next; 24 | 25 | }); 26 | -------------------------------------------------------------------------------- /src/routes/preconditions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const error = require('http-errors'); 4 | const semver = require('semver'); 5 | const meta = require('../../package'); 6 | 7 | module.exports = router => { 8 | 9 | //JSON headers. 10 | router.use((req, res, next) => { 11 | next(req.accepts('json') ? null : error(406, 'Must accept JSON response')); 12 | }); 13 | 14 | //API-version. 15 | router.use((req, res, next) => { 16 | 17 | const v = req.get('api-version') || req.query._apiv; 18 | 19 | if(!semver.valid(v)) { 20 | return void next(error(400, 'No valid API version provided')); 21 | } 22 | 23 | if(semver.gt(v, meta.version)) { 24 | return void next(error(400, `API version ${v} does not exist yet`)); 25 | } 26 | 27 | return void next(); 28 | 29 | }); 30 | 31 | }; 32 | -------------------------------------------------------------------------------- /src/util/template.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Allows for creating one-line strings over multiple lines with template strings. 5 | * 6 | * Behaves like how HTML treats strings over multiple lines. Newlines are turned 7 | * into spaces, multiple concurrent spaces are treated as one. 8 | * 9 | * @return {String} The final concatenated string. 10 | */ 11 | exports.line = function(strings) { 12 | const values = Array.from(arguments).slice(1); 13 | return strings 14 | .map((v, i) => v.replace(/[\n\s\r ]+/g, ' ') + (values[i] || '')) 15 | .join('') 16 | .trim(' \n'); 17 | }; 18 | 19 | //Create a URL string by encoding the values. 20 | exports.url = function(strings) { 21 | const values = Array.from(arguments).slice(1); 22 | return strings.map((v, i) => v + encodeURIComponent(values[i] || '')).join(''); 23 | }; 24 | -------------------------------------------------------------------------------- /src/bootstrap/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {App, Middleware} = require('momi'); 4 | const {createTokenPair, tokenToSession, verifyTokenPair} = require('../services/auth'); 5 | const {putService, getService} = require('../util/service'); 6 | const {map, T} = require('../prelude'); 7 | 8 | // load :: a -> (a -> Future b c) -> Middleware b c 9 | const load = map(map(Middleware.lift), T); 10 | 11 | module.exports = App.do(function*(next) { 12 | const {tokenLife, refreshLife} = yield getService('config').chain(load('security')); 13 | const {encode, decode} = yield getService('token'); 14 | yield putService('auth', { 15 | createTokenPair: createTokenPair(tokenLife, refreshLife, encode), 16 | tokenToSession: tokenToSession(tokenLife, decode, Object), 17 | verifyTokenPair: verifyTokenPair(tokenLife, refreshLife) 18 | }); 19 | return yield next; 20 | }); 21 | -------------------------------------------------------------------------------- /src/bootstrap/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const {App, Middleware} = require('momi'); 5 | const router = require('../util/route'); 6 | const setupErrorHandling = require('../routes/error'); 7 | const {putService} = require('../util/service'); 8 | 9 | module.exports = App.do(function*(next) { 10 | 11 | const app = express(); 12 | const {services} = yield Middleware.get; 13 | 14 | //Nothing powers our applications. 15 | app.set('x-powered-by', false); 16 | 17 | //Attach services to every request object. 18 | app.use((req, res, next) => { 19 | req.services = services; 20 | next(); 21 | }); 22 | 23 | //Load routes. Order is important. 24 | const loadRoutes = router(app); 25 | loadRoutes('common'); 26 | loadRoutes('preconditions'); 27 | loadRoutes('app'); 28 | setupErrorHandling(app); 29 | 30 | //Attach the app as a service. 31 | yield putService('app', app); 32 | 33 | return yield next; 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /src/routes/error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createError = require('http-errors'); 4 | const {line} = require('../util/template'); 5 | const serialize = require('serialize-http-error'); 6 | const log = require('../util/log'); 7 | 8 | module.exports = router => { 9 | 10 | //Stacks that reach to here are 404. 11 | router.all('*', (req, res, next) => next(createError(404, line ` 12 | There's nothing at ${req.method.toUpperCase()} ${req.path} 13 | `))); 14 | 15 | //Log errors. 16 | router.use((err, req, res, next) => { 17 | 18 | if(!err.status || err.status >= 500) { 19 | log.error(`${req.name}: Errored: ${err && err.message || err}`); 20 | } else { 21 | log.info(`${req.name}: [${err.status}] ${err.message}`); 22 | } 23 | 24 | return void next(err); 25 | 26 | }); 27 | 28 | //Error responses. 29 | //Respond with JSON errors. 30 | router.use((err, req, res, next) => { //eslint-disable-line 31 | res.status(err.status || 500).send(serialize(err)); 32 | }); 33 | 34 | }; 35 | -------------------------------------------------------------------------------- /test/unit/framework/validate.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const validate = require('../../../src/util/validate'); 4 | const Future = require('fluture'); 5 | const {BadRequest} = require('http-errors'); 6 | const t = require('tcomb'); 7 | 8 | describe('Validation framework', () => { 9 | 10 | describe('.validate()', () => { 11 | 12 | it('returns a Future', () => { 13 | expect(validate(t.String, '')).to.be.an.instanceof(Future); 14 | }); 15 | 16 | it('rejects with BadRequest error when given invalid input', done => { 17 | validate(t.String, {not: 'a string'}).fork( 18 | err => { 19 | expect(err).to.be.an.instanceof(BadRequest); 20 | done(); 21 | }, 22 | () => done(new Error('It did not reject')) 23 | ); 24 | }); 25 | 26 | it('resolves with the input value when valid', done => { 27 | validate(t.String, 'a string').fork(done, v => { 28 | expect(v).to.equal('a string'); 29 | done(); 30 | }); 31 | }); 32 | 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /src/util/version.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const semver = require('semver'); 4 | const error = require('http-errors'); 5 | const {find, maybe} = require('../prelude'); 6 | 7 | const predicates = { 8 | satisfies: 'compliant with', 9 | gt: 'greater than', 10 | gte: 'greater than or equal to', 11 | lt: 'less than', 12 | lte: 'less than or equal to', 13 | eq: 'equal to', 14 | neq: 'different from' 15 | }; 16 | 17 | module.exports = callbacks => (req, res, next) => { 18 | const userVersion = req.header('api-version'); 19 | const versions = Object.keys(callbacks); 20 | const dispatch = k => _ => callbacks[k](req, res, next); 21 | const match = find(v => semver.satisfies(userVersion, v), versions); 22 | maybe(next, dispatch, match)(); 23 | }; 24 | 25 | Object.keys(predicates).forEach(k => { 26 | 27 | module.exports[k] = version => (req, res, next) => { 28 | const userVersion = req.header('api-version'); 29 | next(semver[k](userVersion, version) ? null : error( 30 | 412, `Version ${userVersion} is not ${predicates[k]} version ${version}`, 31 | {expected: version, actual: userVersion, predicate: k} 32 | )); 33 | }; 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /test/unit/util/middleware.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Future = require('fluture'); 4 | const {Middleware} = require('momi'); 5 | const {race} = require('../../../src/util/middleware'); 6 | 7 | describe('Middleware utilities', () => { 8 | 9 | describe('race', () => { 10 | 11 | const mx = Middleware.of('x'); 12 | const mslow = Middleware.lift(Future.after(20, 'slow')); 13 | 14 | it('returns a Middleware', () => { 15 | expect(race(mx, mx)).to.be.an.instanceof(Middleware); 16 | }); 17 | 18 | it('races two middleware against one another', done => { 19 | const m1 = race(mx, mslow); 20 | const m2 = race(mslow, mx); 21 | const m3 = race(mslow, mslow); 22 | const r1 = m1.run('state1'); 23 | const r2 = m2.run('state2'); 24 | const r3 = m3.run('state3'); 25 | r1.fork(done, x => { 26 | expect(x).to.have.property('_1', 'x'); 27 | expect(x).to.have.property('_2', 'state1'); 28 | }); 29 | r2.fork(done, x => { 30 | expect(x).to.have.property('_1', 'x'); 31 | expect(x).to.have.property('_2', 'state2'); 32 | }); 33 | r3.fork(done, x => { 34 | expect(x).to.have.property('_1', 'slow'); 35 | expect(x).to.have.property('_2', 'state3'); 36 | }); 37 | setTimeout(done, 50, null); 38 | }); 39 | 40 | }); 41 | 42 | }); 43 | -------------------------------------------------------------------------------- /src/routes/common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {line} = require('../util/template'); 4 | const log = require('../util/log'); 5 | const whitelist = require('config').get('server.cors'); 6 | const cookieParser = require('cookie-parser'); 7 | 8 | module.exports = router => { 9 | 10 | //Give requests a name for logging purposes. 11 | router.use((req, res, next) => { 12 | req.name = line ` 13 | ${req.xhr ? 'AJAX' : ''} 14 | ${req.method.toUpperCase()} 15 | ${req.originalUrl} 16 | `; 17 | next(); 18 | }); 19 | 20 | //Request logging. 21 | router.use((req, res, next) => { 22 | log.verbose(req.name); 23 | next(); 24 | }); 25 | 26 | //Access control. 27 | router.use((req, res, next) => { 28 | 29 | if(!whitelist.includes(req.headers.origin)) { 30 | return void (req.method === 'OPTIONS' ? res.end() : next()); 31 | } 32 | 33 | res.header('Access-Control-Allow-Origin', req.headers.origin); 34 | res.header('Access-Control-Allow-Credentials', 'true'); 35 | 36 | if(req.method !== 'OPTIONS') { 37 | return void next(); 38 | } 39 | 40 | res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH'); 41 | res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']); 42 | return void res.end(); 43 | 44 | }); 45 | 46 | //Parse cookies in all GET requests. 47 | router.get('*', cookieParser()); 48 | 49 | }; 50 | -------------------------------------------------------------------------------- /src/util/dispatch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Future = require('fluture'); 4 | const log = require('./log'); 5 | 6 | const send = (res, val) => { 7 | 8 | log.debug('Sending response'); 9 | 10 | if(typeof val.pipe === 'function') { 11 | return void val.pipe(res); 12 | } 13 | 14 | return void res.json(val); 15 | 16 | }; 17 | 18 | const forkAction = (res, next) => val => void ( 19 | res.headersSent 20 | ? undefined 21 | : val === null || val === undefined 22 | ? next() 23 | : send(res, val) 24 | ); 25 | 26 | const runAction = (action, req, res, next) => { 27 | const ret = action(req, res); 28 | return ret instanceof Future ? (ret.fork(next, forkAction(res, next)), true) : false; 29 | }; 30 | 31 | const createDispatcher = file => { 32 | const action = require(`../actions/${file}`); 33 | log.debug('Create action dispatcher: %s', file); 34 | return function dispatcher(req, res, next) { 35 | if(!runAction(action, req, res, next)) { 36 | throw new TypeError(`The "${file}"-action did not return a Future`); 37 | } 38 | }; 39 | }; 40 | 41 | const middleware = action => function dispatcher(req, res, next) { 42 | if(!runAction(action, req, res, next)) { 43 | throw new TypeError('An action did not return a Future'); 44 | } 45 | }; 46 | 47 | module.exports = createDispatcher; 48 | module.exports.send = send; 49 | module.exports.forkAction = forkAction; 50 | module.exports.middleware = middleware; 51 | -------------------------------------------------------------------------------- /src/bootstrap/http.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const {getService} = require('../util/service'); 5 | const log = require('../util/log'); 6 | const {race} = require('../util/middleware'); 7 | const {Middleware, App} = require('momi'); 8 | const Future = require('fluture'); 9 | const {map, T} = require('../prelude'); 10 | 11 | const mountApp = (app, host, port) => Middleware.lift(Future.node(done => { 12 | const conn = http.createServer(app).listen(port, host, err => done(err, conn)); 13 | })); 14 | 15 | module.exports = App.do(function*(next) { 16 | 17 | const config = yield getService('config').chain(map(Middleware.lift, T('server'))); 18 | 19 | log.verbose('HTTP server starting...'); 20 | 21 | const app = yield getService('app'); 22 | const connections = new Set; 23 | const server = yield mountApp(app, config.host, config.port); 24 | 25 | server.on('connection', connection => { 26 | connection.once('close', _ => connections.delete(connection)); 27 | connections.add(connection); 28 | }); 29 | 30 | const addr = server.address(); 31 | log.info(`HTTP server started on ${addr.address}:${addr.port}`); 32 | 33 | const res = yield race(next, Middleware.fromComputation(rej => { 34 | server.once('error', rej); 35 | })); 36 | 37 | log.verbose('HTTP server stopping...'); 38 | 39 | connections.forEach(c => c.destroy()); 40 | yield Middleware.lift(Future.node(server.close.bind(server))); 41 | 42 | log.verbose('HTTP server stopped'); 43 | 44 | return res; 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /test/unit/framework/dispatch.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {send, forkAction} = require('../../../src/util/dispatch'); 4 | 5 | const noop = () => {}; 6 | 7 | describe('Dispatch framework', () => { 8 | 9 | describe('.forkAction()', () => { 10 | 11 | it('always returns undefined', () => { 12 | const tests = [ 13 | {res: {headersSent: true, json: noop}, val: null}, 14 | {res: {headersSent: true, json: noop}, val: 'hi'}, 15 | {res: {headersSent: false, json: noop}, val: null}, 16 | {res: {headersSent: false, json: noop}, val: 'hi'} 17 | ]; 18 | tests.forEach(({res, val}) => expect(forkAction(res, noop)(val)).to.be.undefined); 19 | }); 20 | 21 | it('calls next when given a null value', () => { 22 | const res = {headersSent: false, json: noop}; 23 | const next = sinon.spy(); 24 | const val = null; 25 | forkAction(res, next)(val); 26 | expect(next).to.have.been.called; 27 | }); 28 | 29 | }); 30 | 31 | describe('.send()', () => { 32 | 33 | it('calls val.pipe with the res', () => { 34 | const val = {pipe: sinon.spy()}; 35 | const res = {headersSent: false}; 36 | send(res, val); 37 | expect(val.pipe).to.have.been.calledWith(res); 38 | }); 39 | 40 | it('calls res.json with the value', () => { 41 | const val = 'hi'; 42 | const res = {headersSent: false, json: sinon.spy()}; 43 | send(res, val); 44 | expect(res.json).to.have.been.calledWith(val); 45 | }); 46 | 47 | }); 48 | 49 | }); 50 | -------------------------------------------------------------------------------- /src/services/token.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const jwt = require('jwt-simple'); 4 | const { 5 | ftap, 6 | map, 7 | get, 8 | pipe, 9 | encase, 10 | maybeToEither, 11 | chain, 12 | filterM, 13 | equals, 14 | is 15 | } = require('../prelude'); 16 | 17 | // ALGORITHM :: Algorithm 18 | const ALGORITHM = 'HS256'; 19 | 20 | // VERSION :: Number 21 | const VERSION = 1; 22 | 23 | // encodeFailure :: Error 24 | const encodeFailure = new Error('Failed to encode token'); 25 | 26 | // invalidToken :: Error 27 | const invalidToken = new Error('Token was invalid or had an invalid format or version number'); 28 | 29 | // safeEncode :: (Object, String, Algorithm) -> Either Error String 30 | const safeEncode = (a, b, c) => encase(_ => jwt.encode(a, b, c))(0); 31 | 32 | // safeDecode :: (String, String, Boolean, Algorithm) -> Either Error Object 33 | const safeDecode = (a, b, c, d) => encase(_ => jwt.decode(a, b, c, d))(0); 34 | 35 | // default :: String -> TokenService 36 | module.exports = secret => { 37 | const encodeToken = payload => safeEncode(payload, secret, ALGORITHM); 38 | const decodeToken = token => safeDecode(token, secret, false, ALGORITHM); 39 | const encode = pipe([d => ({d, v: VERSION}), encodeToken, maybeToEither(encodeFailure)]); 40 | const decode = pipe([ 41 | decodeToken, 42 | chain(ftap(map(filterM(equals(VERSION)), get(is(Number), 'v')))), 43 | chain(get(is(Object), 'd')), 44 | maybeToEither(invalidToken) 45 | ]); 46 | return {ALGORITHM, VERSION, encodeToken, decodeToken, encode, decode}; 47 | }; 48 | -------------------------------------------------------------------------------- /src/actions/auth/create.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Authentication, Authorization} = require('../../domain/models'); 4 | const validate = require('../../util/validate'); 5 | const error = require('http-errors'); 6 | const Future = require('fluture'); 7 | const bcrypt = require('bcrypt'); 8 | const {K, prop, get, fromMaybe, pipe, maybeToFuture, chain, is} = require('../../prelude'); 9 | 10 | // invalidCredentials :: UnauthorizedError 11 | const invalidCredentials = error(401, 'Invalid credentials'); 12 | 13 | //verify :: (String, String) -> Future Error True 14 | const verify = (pass, hash) => Future.node(done => bcrypt.compare(pass, hash, done)); 15 | 16 | module.exports = (req, res) => Future.do(function*() { 17 | 18 | // findUserByName :: UserId -> Future UnauthorizedError User 19 | const findUserByName = pipe([ 20 | req.services.users.get, 21 | chain(maybeToFuture(invalidCredentials)) 22 | ]); 23 | 24 | const auth = yield validate(Authentication, req.body); 25 | const user = yield findUserByName(auth.username); 26 | yield verify(auth.password, user.password) 27 | .chain(ok => ok ? Future.of(ok) : Future.reject(invalidCredentials)) 28 | .mapRej(K(invalidCredentials)); 29 | 30 | const [token, refresh] = yield req.services.auth.createTokenPair({ 31 | user: prop('username', user), 32 | groups: fromMaybe([], get(is(Array), 'groups', user)) 33 | }); 34 | 35 | res.cookie('token', token, { 36 | path: '/', 37 | maxAge: yield req.services.config('security.tokenLife') 38 | }); 39 | 40 | return Authorization({token, refresh}); 41 | 42 | }); 43 | -------------------------------------------------------------------------------- /src/domain/types.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const def = require('../util/typedef'); 4 | const tcomb = require('tcomb'); 5 | const {union} = require('tcomb'); 6 | const {Readable} = require('stream'); 7 | const {test, complement: not} = require('../prelude'); 8 | 9 | //Extend tcomb default types. 10 | const T = Object.create(tcomb); 11 | module.exports = T; 12 | 13 | //between :: Number, Number -> Number -> Boolean 14 | const between = (a, b) => x => x >= a && x <= b; 15 | 16 | //is :: Function -> Any -> Boolean 17 | const is = cons => x => x instanceof cons; 18 | 19 | T.ResponseStatus = def('ResponseStatus', T.Integer, { 20 | 'must be a value betwen 100 and 599': not(between(100, 599)) 21 | }); 22 | 23 | T.ResponseHeader = def('ResponseHeader', T.String, { 24 | 'may only contain alphanumeric characters and dashes': test(/[^a-zA-Z0-9-]/) 25 | }); 26 | 27 | T.ReadableStream = def('ReadableStream', T.Any, { 28 | 'is not an instance of ReadableStream': not(is(Readable)) 29 | }); 30 | 31 | T.ResponseBody = union([ 32 | T.String, T.ReadableStream 33 | ], 'ResponseBody'); 34 | 35 | T.Username = def('Username', T.String, { 36 | 'must be between 3 and 32 characters long': x => not(between(3, 32))(x.length), 37 | 'should contain only lowercase characters, numbers and underscores': test(/[^a-z0-9_]/), 38 | 'should not start with an underscore': test(/^_/), 39 | 'should not end with an underscore': test(/_$/), 40 | 'should not contain double underscores': test(/_{2}/) 41 | }); 42 | 43 | T.Password = def('Password', T.String, { 44 | 'must be between 8 and 64 characters long': x => not(between(8, 64))(x.length) 45 | }); 46 | 47 | T.Group = def('Group', T.String, {}); 48 | -------------------------------------------------------------------------------- /src/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {env, MaybeType, EitherType} = require('sanctuary'); 4 | const $ = require('sanctuary-def'); 5 | const {TypeVariable, NullaryType, UnaryType, UnaryTypeVariable, StrMap} = $; 6 | const FutureTypes = require('fluture-sanctuary-types'); 7 | const {Readable} = require('stream'); 8 | 9 | const E = module.exports; 10 | 11 | const last = xs => xs[xs.length - 1]; 12 | 13 | env.forEach(type => E[`$${last(type.name.split('/'))}`] = type); 14 | 15 | E.$a = TypeVariable('a'); 16 | E.$b = TypeVariable('b'); 17 | E.$c = TypeVariable('c'); 18 | E.$d = TypeVariable('d'); 19 | E.$e = TypeVariable('e'); 20 | E.$f = UnaryTypeVariable('f'); 21 | E.$g = TypeVariable('g'); 22 | E.$m = UnaryTypeVariable('m'); 23 | 24 | E.$Either = EitherType; 25 | E.$Maybe = MaybeType; 26 | E.$Array = $.Array; 27 | E.$Function = $.Function; 28 | E.$Pair = $.Pair; 29 | E.$StrMap = StrMap; 30 | 31 | E.$Future = FutureTypes.FutureType; 32 | E.$ConcurrentFuture = FutureTypes.ConcurrentFutureType; 33 | 34 | E.$ReadableStream = UnaryType( 35 | 'ReadableStream', 36 | 'https://nodejs.org/api/stream.html#stream_readable_streams', 37 | x => x instanceof Readable, 38 | x => x._readableState.buffer.head ? [x._readableState.buffer.head.data] : [] 39 | ); 40 | 41 | E.$List = UnaryType( 42 | 'List', 43 | 'https://developer.mozilla.org/en-US/search?q=indexOf&topic=js', 44 | x => typeof x.indexOf === 'function', 45 | xs => typeof xs === 'string' ? [] : xs 46 | ); 47 | 48 | E.$Buffer = NullaryType('Buffer', '', x => x instanceof Buffer); 49 | 50 | E.env = env.concat(FutureTypes.env).concat([ 51 | E.$ReadableStream($.Unknown), 52 | E.$Pair($.Unknown, $.Unknown), 53 | E.$Buffer 54 | ]); 55 | -------------------------------------------------------------------------------- /test/unit/services/token.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createTokenService = require('../../../src/services/token'); 4 | const {chain} = require('../../../src/prelude'); 5 | 6 | describe('Token service', () => { 7 | 8 | const secret = 'suchsecret'; 9 | const data = {such: 'data'}; 10 | const {encode, decode, encodeToken} = createTokenService(secret); 11 | 12 | describe('.encode()', () => { 13 | 14 | it('returns an Either String', () => { 15 | const decoded = encode(data); 16 | expect(decoded.isRight).to.equal(true); 17 | expect(decoded.value).to.be.a('string'); 18 | }); 19 | 20 | }); 21 | 22 | describe('.decode()', () => { 23 | 24 | const encoded = encode(data); 25 | 26 | it('fails to decode with the wrong secret', () => { 27 | const decoded = chain(createTokenService('notsecret').decode, encoded); 28 | expect(decoded.isLeft).to.equal(true); 29 | }); 30 | 31 | it('fails to decode with a wrong format', () => { 32 | [{}, {d: {}}, {v: 1}, {v: '', d: {}}, {v: 1, d: ''}].forEach(invalid => { 33 | const encoded = encodeToken(invalid); 34 | const decoded = chain(decode, encoded); 35 | expect(decoded.isLeft).to.equal(true); 36 | }); 37 | }); 38 | 39 | it('fails to decode with the wrong version', () => { 40 | const encoded = encodeToken({v: -1, d: data}); 41 | const decoded = chain(decode, encoded); 42 | expect(decoded.isLeft).to.equal(true); 43 | }); 44 | 45 | it('returns an Either of the encoded data', () => { 46 | const decoded = chain(decode, encoded); 47 | expect(decoded.isRight).to.equal(true); 48 | expect(decoded.value).to.deep.equal(data); 49 | }); 50 | 51 | }); 52 | 53 | }); 54 | -------------------------------------------------------------------------------- /config/default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // 4 | // WARNING 5 | // Do not modify this file! Instead read the readme on how to do configuration! 6 | // 7 | 8 | const {transports} = require('winston'); 9 | 10 | module.exports = { 11 | 12 | //Web server configuration. 13 | server: { 14 | 15 | //White-list of domains allowed to access our resources. 16 | cors: [], 17 | 18 | //IP address to bind to. 19 | host: '0.0.0.0', 20 | 21 | //Port to listen on. 22 | port: 3000 23 | 24 | }, 25 | 26 | //Logs 27 | log: { 28 | 29 | //Common log level (for all transports) 30 | level: 'verbose', 31 | 32 | //Winston transports 33 | transports: [ 34 | new transports.Console({ 35 | align: true, 36 | colorize: true, 37 | timestamp: false 38 | }) 39 | ] 40 | }, 41 | 42 | //Security-related parameters. 43 | security: { 44 | 45 | //Please configure the secret on your deployment server. Every instance 46 | //must have the same secret, but the secret should not be comitted. 47 | secret: null, 48 | 49 | //Please note that the token-life also determines the time it takes for 50 | //changes in user-permissions to be propagated. 51 | tokenLife: 21600000, /*6 hours*/ 52 | refreshLife: 1209600000 /*14 days*/ 53 | 54 | }, 55 | 56 | //Group permission configuration. Matches made using https://github.com/jonschlinkert/micromatch 57 | //All users are automatically part of the @everyone group. 58 | //All logged-out users are automatically part of the @unauthenticated group. 59 | //All logged-in users are automatically part of the @authenticated group. 60 | permissions: { 61 | '@everyone': ['auth.*', 'ping'], 62 | '@unauthenticated': [], 63 | '@authenticated': ['*'] 64 | } 65 | 66 | }; 67 | -------------------------------------------------------------------------------- /src/actions/auth/update.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {Authorization, Session} = require('../../domain/models'); 4 | const validate = require('../../util/validate'); 5 | const Future = require('fluture'); 6 | const error = require('http-errors'); 7 | const { 8 | eitherToFuture, 9 | maybeToFuture, 10 | chain, 11 | apply, 12 | map, 13 | pipe, 14 | concat, 15 | prop, 16 | fromMaybe, 17 | get, 18 | is 19 | } = require('../../prelude'); 20 | 21 | // userNotFound :: NotAuthorizedError 22 | const userNotFound = error(403, 'User provided by token does not exist'); 23 | 24 | // arrof :: a -> Array a 25 | const arrof = x => [x]; 26 | 27 | module.exports = (req, res) => Future.do(function*() { 28 | 29 | // verifyTokens :: Monad m => (m AuthorizationToken, m RefreshToken) -> m Session 30 | const verifyTokens = (token, refresh) => chain( 31 | apply(req.services.auth.verifyTokenPair), 32 | concat(map(arrof, token), map(arrof, refresh)) 33 | ); 34 | 35 | // findUserByName :: UserId -> Future NotFoundError User 36 | const findUserByName = pipe([ 37 | req.services.users.get, 38 | chain(maybeToFuture(userNotFound)) 39 | ]); 40 | 41 | const auth = yield validate(Authorization, req.body); 42 | 43 | const session = yield eitherToFuture(verifyTokens( 44 | req.services.token.decode(auth.token), 45 | req.services.token.decode(auth.refresh) 46 | )).chain(validate(Session)); 47 | 48 | const user = yield findUserByName(session.user); 49 | 50 | const [token, refresh] = yield req.services.auth.createTokenPair({ 51 | user: prop('username', user), 52 | groups: fromMaybe([], get(is(Array), 'groups', user)) 53 | }); 54 | 55 | res.cookie('token', token, { 56 | path: '/', 57 | maxAge: yield req.services.config('security.tokenLife') 58 | }); 59 | 60 | return Authorization({token, refresh}); 61 | 62 | }); 63 | -------------------------------------------------------------------------------- /src/util/hash.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | const {node} = require('fluture'); 5 | const {createHash} = require('crypto'); 6 | const {slice, pipe, encodeBuffer, maybeToFuture} = require('../prelude'); 7 | 8 | /** 9 | * Hashes a string to an integer. 10 | * 11 | * @sig strToInt :: String -> Number 12 | * 13 | * @param {String} str The string to hash. 14 | * 15 | * @return {Number} An integer. 16 | */ 17 | exports.strToInt = str => { 18 | 19 | if(str.length === 0) { 20 | return 0; 21 | } 22 | 23 | let hash = 0; 24 | 25 | for(let i = 0; i < str.length; i++) { 26 | const char = str.charCodeAt(i); 27 | hash = ((hash << 5) - hash) + char; 28 | hash = hash & hash; 29 | } 30 | 31 | return hash; 32 | 33 | }; 34 | 35 | /** 36 | * Hash a string to hexidecimal MD5. 37 | * 38 | * @param {String} str The value to hash. 39 | * 40 | * @return {String} 32 Hexidecimal numbers. 41 | */ 42 | const hexmd5 = exports.hexmd5 = x => createHash('md5').update(x).digest().toString('hex'); 43 | 44 | /** 45 | * Hash an object to hexmd5. 46 | * 47 | * @param {Object} obj Object to hash. 48 | * 49 | * @return {String} MD5 Hash. 50 | */ 51 | exports.objectToString = pipe([JSON.stringify, hexmd5]); 52 | 53 | /** 54 | * Get a random string of the given amount of characters. 55 | * 56 | * Currently the string is hexidecimal, so it contains an amount of bytes of 57 | * entropy equal to half the amount of characters. This may change in the future 58 | * but could be something to keep in mind. 59 | * 60 | * @sig randomString :: Integer -> Future[Error, String] 61 | * 62 | * @param {Number} size The amount of characters the string should have. 63 | * 64 | * @return {Future} A Future of the random string. 65 | */ 66 | exports.randomString = size => ( 67 | node(done => crypto.randomBytes(Math.ceil(size / 2), done)) 68 | .map(encodeBuffer('hex')) 69 | .map(slice(0, size)) 70 | .chain(maybeToFuture(new RangeError('Too few bytes'))) 71 | ); 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PROJECT_NAME", 3 | "version": "0.1.0", 4 | "description": "PROJECT_DESCRIPTION", 5 | "main": "index.js", 6 | "repository": "PROJECT_REPOSITORY", 7 | "scripts": { 8 | "clean": "rimraf npm-debug.log coverage .nyc_output", 9 | "lint": "eslint src test config", 10 | "start": "NODE_ENV=development pm2-dev .", 11 | "pm2": "pm2 start ecosystem.config.js && pm2 logs", 12 | "pretest": "npm run clean && npm prune && nsp check && npm run lint", 13 | "test": "nyc mocha --include src/**.js --opts test/mocha.opts test", 14 | "coverage": "nyc report --reporter=lcov && opn ./coverage/lcov-report/index.html", 15 | "coverage:codecov": "nyc report --reporter=text-lcov > coverage.lcov && codecov" 16 | }, 17 | "engines": { 18 | "node": ">=5.0.0" 19 | }, 20 | "author": "PROJECT_AUTHOR", 21 | "license": "MIT", 22 | "dependencies": { 23 | "bcrypt": "^1.0.1", 24 | "body-parser": "^1.15.2", 25 | "config": "^1.16.0", 26 | "cookie-parser": "^1.4.3", 27 | "express": "^4.13.3", 28 | "fluture": "^6.2.3", 29 | "fluture-sanctuary-types": "^1.2.0", 30 | "http-errors": "^1.4.0", 31 | "json5": "^0.5.0", 32 | "jwt-simple": "^0.5.0", 33 | "micromatch": "^3.0.2", 34 | "momi": "^0.6.0", 35 | "pm2": "^2.4.6", 36 | "request": "^2.72.0", 37 | "sanctuary": "^0.13.1", 38 | "sanctuary-def": "^0.12.0", 39 | "sanctuary-type-classes": "^6.0.0", 40 | "semver": "^5.3.0", 41 | "serialize-http-error": "^1.0.0", 42 | "tcomb": "^3.0.0", 43 | "tcomb-validation": "^3.0.0", 44 | "winston": "^2.3.0" 45 | }, 46 | "devDependencies": { 47 | "chai": "^4.0.1", 48 | "chai-things": "^0.2.0", 49 | "codecov": "^2.2.0", 50 | "eslint": "^4.2.0", 51 | "eslint-config-warp": "^2.1.0", 52 | "glob": "^7.0.0", 53 | "mocha": "^3.1.2", 54 | "nsp": "^2.3.0", 55 | "nyc": "^11.0.1", 56 | "opn-cli": "^3.1.0", 57 | "rimraf": "^2.6.1", 58 | "sinon": "^2.3.2", 59 | "sinon-chai": "^2.10.0", 60 | "supertest": "^3.0.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/unit/util/hash.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('../../../src/util/hash'); 4 | const Future = require('fluture'); 5 | const {range, curry2} = require('../../../src/prelude'); 6 | 7 | describe('Hashing utililities', () => { 8 | 9 | const words = 'the quick brown fox jumped over the lazy dog'.split(' '); 10 | 11 | const assertHash = v => { 12 | expect(v).to.be.a('string'); 13 | expect(v).to.have.length(32); 14 | expect(v).not.to.match(/[^0-9a-f]/); 15 | }; 16 | 17 | describe('.strToInt()', () => { 18 | 19 | const assertInteger = v => { 20 | expect(v).to.be.a('number'); 21 | expect(v % 1).to.equal(0); 22 | }; 23 | 24 | it('creates integers', () => { 25 | words.forEach(v => assertInteger(util.strToInt(v))); 26 | }); 27 | 28 | it('returns 0 for empty strings', () => { 29 | const r = util.strToInt(''); 30 | assertInteger(r); 31 | expect(r).to.equal(0); 32 | }); 33 | 34 | }); 35 | 36 | describe('.hexmd5()', () => { 37 | 38 | it('creates hashes', () => { 39 | words.forEach(v => assertHash(util.hexmd5(v))); 40 | }); 41 | 42 | }); 43 | 44 | describe('.objectToString()', () => { 45 | 46 | it('creates hashes', () => { 47 | const objects = words.map(word => ({key: word})); 48 | objects.forEach(v => assertHash(util.objectToString(v))); 49 | }); 50 | 51 | }); 52 | 53 | describe('.randomString()', () => { 54 | 55 | it('returns a Future', () => { 56 | expect(util.randomString(4)).to.be.an.instanceof(Future); 57 | }); 58 | 59 | it('resolves with a string', done => { 60 | util.randomString(4).fork(done, v => { 61 | expect(v).to.be.a('string'); 62 | done(); 63 | }); 64 | }); 65 | 66 | it('string always has the right length', done => { 67 | const fs = range(0, 32).map(l => 68 | util.randomString(l).map(s => void expect(s).to.have.length(l)) 69 | ); 70 | Future.parallel(Infinity, fs).fork(done, curry2(done, null)); 71 | }); 72 | 73 | }); 74 | 75 | }); 76 | -------------------------------------------------------------------------------- /test/unit/framework/typedef.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const typedef = require('../../../src/util/typedef'); 4 | const t = require('tcomb'); 5 | const {validate} = require('tcomb-validation'); 6 | 7 | describe('Type definition framework', () => { 8 | 9 | describe('.typedef()', () => { 10 | 11 | let ShortString; 12 | 13 | before('create ShortString definition', () => { 14 | ShortString = typedef('ShortString', t.String, { 15 | 'must not be empty': s => s.length < 1, 16 | 'is too long': s => s.length > 3 17 | }); 18 | }); 19 | 20 | it('is a function', () => { 21 | expect(ShortString).to.be.a('function'); 22 | }); 23 | 24 | it('throws when invalid', () => { 25 | expect(() => ShortString('12345')).to.throw(TypeError); 26 | }); 27 | 28 | it('returns input when valid', () => { 29 | expect(ShortString('123')).to.equal('123'); 30 | }); 31 | 32 | it('allows tcomb-validation to get at the error message', () => { 33 | const v = validate('12345', ShortString); 34 | expect(v).to.have.property('errors'); 35 | expect(v.errors).to.be.an('array'); 36 | expect(v.firstError()).to.have.property('message'); 37 | expect(v.firstError().message).to.be.a('string'); 38 | }); 39 | 40 | it('gives the right error message based on predicates', () => { 41 | const empty = validate('', ShortString); 42 | const tooLong = validate('12345', ShortString); 43 | expect(empty.firstError().message).to.equal('The ShortString must not be empty'); 44 | expect(tooLong.firstError().message).to.equal('The ShortString is too long'); 45 | }); 46 | 47 | //I know ShortBlob is an oxymoron, but it sounds so cute. 48 | it('cascades to supertype error messages', () => { 49 | const ShortBlob = typedef('ShortBlob', ShortString, { 50 | 'is not binary': s => (/[01]*/).test(s) 51 | }); 52 | const empty = validate('', ShortBlob); 53 | const hello = validate('hi', ShortBlob); 54 | expect(empty.firstError().message).to.equal('The ShortString must not be empty'); 55 | expect(hello.firstError().message).to.equal('The ShortBlob is not binary'); 56 | }); 57 | 58 | it('gives the right error messages for structs', () => { 59 | 60 | const User = t.struct({ 61 | name: ShortString 62 | }, 'User'); 63 | 64 | const v = validate({name: '12345'}, User); 65 | 66 | expect(v.firstError().message).to.equal('The \'name\' is too long'); 67 | 68 | }); 69 | 70 | }); 71 | 72 | }); 73 | -------------------------------------------------------------------------------- /test/unit/prelude.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const P = require('../../src/prelude'); 4 | const Future = require('fluture'); 5 | const {Just, Nothing, Left, Right, isLeft, isRight} = require('../../src/prelude'); 6 | 7 | const noop = () => {}; 8 | const error = new Error('kaputt'); 9 | 10 | describe('Prelude', () => { 11 | 12 | describe('.maybeToFuture()', () => { 13 | 14 | it('returns a resolved Future from a Just', () => { 15 | const spy = sinon.spy(); 16 | const future = P.maybeToFuture(error, Just('It worked')); 17 | expect(future).to.be.an.instanceof(Future); 18 | future.fork(noop, spy); 19 | expect(spy).to.have.been.calledWith('It worked'); 20 | }); 21 | 22 | it('returns a rejected Future from a Nothing', () => { 23 | const spy = sinon.spy(); 24 | const future = P.maybeToFuture(error, Nothing); 25 | expect(future).to.be.an.instanceof(Future); 26 | future.fork(spy, noop); 27 | expect(spy).to.have.been.calledWith(error); 28 | }); 29 | 30 | }); 31 | 32 | describe('.eitherToFuture()', () => { 33 | 34 | it('returns a resolved Future from a Right', () => { 35 | const spy = sinon.spy(); 36 | const future = P.eitherToFuture(Right('It worked')); 37 | expect(future).to.be.an.instanceof(Future); 38 | future.fork(noop, spy); 39 | expect(spy).to.have.been.calledWith('It worked'); 40 | }); 41 | 42 | it('returns a rejected Future from a Left', () => { 43 | const spy = sinon.spy(); 44 | const future = P.eitherToFuture(Left(error)); 45 | expect(future).to.be.an.instanceof(Future); 46 | future.fork(spy, noop); 47 | expect(spy).to.have.been.calledWith(error); 48 | }); 49 | 50 | }); 51 | 52 | describe('.attempt()', () => { 53 | 54 | it('returns a (Future _ (Right x)) from a (Future _ x)', done => { 55 | const actual = P.attempt(Future.of(1)); 56 | actual.fork( 57 | _ => { 58 | throw Error('The Future should bot have rejected'); 59 | }, 60 | m => { 61 | expect(isRight(m)).to.equal(true); 62 | expect(m.value).to.equal(1); 63 | done(); 64 | } 65 | ); 66 | }); 67 | 68 | it('returns a (Future _ (Left e)) from a (Future e _)', done => { 69 | const actual = P.attempt(Future.reject(1)); 70 | actual.fork( 71 | _ => { 72 | throw Error('The Future should bot have rejected'); 73 | }, 74 | m => { 75 | expect(isLeft(m)).to.equal(true); 76 | expect(m.value).to.equal(1); 77 | done(); 78 | } 79 | ); 80 | }); 81 | 82 | }); 83 | 84 | describe('.ftap()', () => { 85 | 86 | it('returns a Function', () => { 87 | expect(P.ftap(noop)).to.be.a('function'); 88 | }); 89 | 90 | it('ensures the original argument is returned', done => { 91 | const spy = sinon.stub().returns(['foo']); 92 | const f = P.ftap(spy); 93 | f('bar').map(x => { 94 | expect(x).to.equal('bar'); 95 | expect(spy).to.have.been.calledWith('bar'); 96 | done(); 97 | return null; 98 | }); 99 | }); 100 | 101 | }); 102 | 103 | }); 104 | -------------------------------------------------------------------------------- /src/prelude.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {create: createDef} = require('sanctuary-def'); 4 | const {create: createSanctuary} = require('sanctuary'); 5 | const Future = require('fluture'); 6 | const {Functor, Foldable} = require('sanctuary-type-classes'); 7 | const { 8 | env, 9 | $Array, 10 | $Boolean, 11 | $Buffer, 12 | $Either, 13 | $Error, 14 | $Function, 15 | $Future, 16 | $List, 17 | $Maybe, 18 | $Pair, 19 | $ReadableStream, 20 | $String, 21 | $StrMap, 22 | $a, $b, $c, $f 23 | } = require('./env'); 24 | 25 | const checkTypes = process.env.NODE_ENV === 'development' || 26 | process.env.NODE_ENV === 'test'; 27 | 28 | const def = createDef({env, checkTypes}); 29 | 30 | const $ = module.exports = Object.create(createSanctuary({env, checkTypes})); 31 | 32 | //awaitStream :: ReadableStream Buffer -> Future Error Buffer 33 | $.awaitStream = def('awaitStream', 34 | {}, [$ReadableStream($Buffer), $Future($Error, $Buffer)], 35 | stream => Future((rej, res) => { 36 | const chunks = []; 37 | stream.on('data', d => chunks.push(d)); 38 | stream.once('error', rej); 39 | stream.once('end', _ => res(Buffer.concat(chunks))); 40 | return () => stream.destroy(new Error('Cancelled')); 41 | })); 42 | 43 | //maybeToFuture :: a -> Maybe b -> Future a b 44 | $.maybeToFuture = def('maybeToFuture', 45 | {}, [$a, $Maybe($b), $Future($a, $b)], 46 | (e, m) => $.maybe(Future.reject(e), Future.of, m)); 47 | 48 | //eitherToFuture :: Either a b -> Future a b 49 | $.eitherToFuture = def('eitherToFuture', 50 | {}, [$Either($a, $b), $Future($a, $b)], 51 | $.either(Future.reject, Future.of)); 52 | 53 | //attempt :: Future a b -> Future c (Either a b) 54 | $.attempt = def('attempt', 55 | {}, [$Future($a, $b), $Future($c, $Either($a, $b))], 56 | Future.fold($.Left, $.Right)); 57 | 58 | //tap :: (a -> b) -> a -> a 59 | $.tap = def('tap', 60 | {}, [$Function([$a, $b]), $a, $a], 61 | (f, x) => { f(x); return x; }); 62 | 63 | //ftap :: Functor f => (a -> f b) -> a -> f a 64 | $.ftap = def('ftap', 65 | {f: [Functor]}, [$Function([$a, $f($b)]), $a, $f($a)], 66 | (f, x) => $.map(() => x, f(x))); 67 | 68 | //encodeBuffer :: String -> Buffer -> String 69 | $.encodeBuffer = def('encodeBuffer', 70 | {}, [$String, $Buffer, $String], 71 | (encoding, buf) => buf.toString(encoding)); 72 | 73 | //zip :: (a -> b -> c) -> Array a -> Array b -> Array c 74 | $.zip = def('zip', 75 | {}, [$Function([$a, $Function([$b, $c])]), $Array($a), $Array($b), $Array($c)], 76 | (f, xs, ys) => { 77 | const l = Math.min(xs.length, ys.length), zs = new Array(l); 78 | for(let i = 0; i < l; i += 1) { zs[i] = f(xs[i])(ys[i]); } 79 | return zs; 80 | }); 81 | 82 | //contains :: a -> List a -> Boolean 83 | $.contains = def('contains', 84 | {}, [$a, $List($a), $Boolean], 85 | (x, xs) => xs.indexOf(x) >= 0); 86 | 87 | //fst :: Pair a b -> a 88 | $.fst = def('fst', 89 | {}, [$Pair($a, $b), $a], 90 | ([a, _]) => a); 91 | 92 | //snd :: Pair a b -> b 93 | $.snd = def('snd', 94 | {}, [$Pair($a, $b), $b], 95 | ([_, b]) => b); 96 | 97 | //duplicate :: a -> Pair a a 98 | $.duplicate = def('duplicate', 99 | {}, [$a, $Pair($a, $a)], 100 | a => [a, a]); 101 | 102 | //pair :: a -> b -> Pair a b 103 | $.pair = def('pair', 104 | {}, [$a, $b, $Pair($a, $b)], 105 | (a, b) => [a, b]); 106 | 107 | //toStrMap :: Foldable f => f (Pair String a) -> StrMap a 108 | $.toStrMap = def('toStrMap', 109 | {f: [Foldable]}, [$f($Pair($String, $a)), $StrMap($a)], 110 | $.reduce_((o, [k, v]) => Object.assign({[k]: v}, o), {})); 111 | 112 | //replace :: String -> String -> String -> String 113 | $.replace = def('replace', 114 | {}, [$String, $String, $String, $String], 115 | (a, b, s) => s.replace(a, b)); 116 | 117 | //flap :: Functor f => f (a -> b) -> a -> f b 118 | $.flap = def('flap', 119 | {f: [Functor]}, [$f($Function([$a, $b])), $a, $f($b)], 120 | (f, x) => $.map($.T(x), f)); 121 | -------------------------------------------------------------------------------- /src/actions/auth/session.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Future = require('fluture'); 4 | const error = require('http-errors'); 5 | const mm = require('micromatch'); 6 | const { 7 | K, 8 | either, 9 | concat, 10 | pipe, 11 | get, 12 | maybe, 13 | at, 14 | Left, 15 | maybeToEither, 16 | alt, 17 | chain, 18 | ifElse, 19 | test, 20 | splitOn, 21 | trim, 22 | map, 23 | is 24 | } = require('../../prelude'); 25 | 26 | // authenticatedGroups :: Array Group 27 | const authenticatedGroups = ['@everyone', '@authenticated']; 28 | 29 | // unauthenticatedGroups :: Array Group 30 | const unauthenticatedGroups = ['@everyone', '@unauthenticated']; 31 | 32 | // missingPermission :: String -> NotAuthorizedError 33 | const missingPermission = x => error(403, `You are missing the ${x} permission`); 34 | 35 | // missingAuthorizationHeader :: NotAuthorizedError 36 | const missingAuthorizationHeader = error(401, { 37 | name: 'MissingAuthorizationHeaderError', 38 | message: 'Missing Authorization header' 39 | }); 40 | 41 | // missingTokenCookie :: NotAuthorizedError 42 | const missingTokenCookie = error(401, { 43 | name: 'MissingTokenCookieError', 44 | message: 'Missing Cookie header' 45 | }); 46 | 47 | // malformedAuthorizationHeader :: InvalidRequestError 48 | const malformedAuthorizationHeader = error(400, { 49 | name: 'MalformedAuthorizationHeaderError', 50 | message: 'Malformed Authorization header' 51 | }); 52 | 53 | // malformedAuthorizationHeader :: InvalidRequestError 54 | const invalidAuthorizationHeader = error(400, { 55 | name: 'InvalidAuthorizationHeaderError', 56 | message: 'Authorization method must be Bearer' 57 | }); 58 | 59 | // getTokenFromHeaders :: Headers -> Either Error String 60 | const getTokenFromHeaders = pipe([ 61 | get(is(String), 'authorization'), 62 | maybeToEither(missingAuthorizationHeader), 63 | chain(ifElse( 64 | test(/^ *Bearer:/), 65 | pipe([splitOn(':'), at(1), map(trim), maybeToEither(malformedAuthorizationHeader)]), 66 | K(Left(invalidAuthorizationHeader)) 67 | )) 68 | ]); 69 | 70 | // getTokenFromCookies :: Cookies -> Either Error String 71 | const getTokenFromCookies = pipe([ 72 | get(is(String), 'token'), 73 | maybeToEither(missingTokenCookie) 74 | ]); 75 | 76 | // getTokenFromRequest :: Request -> Either Error String 77 | const getTokenFromRequest = req => 78 | req.method === 'GET' 79 | ? alt(getTokenFromCookies(req.cookies), getTokenFromHeaders(req.headers)) 80 | : getTokenFromHeaders(req.headers); 81 | 82 | // getUserGroups :: User -> Array Group 83 | const getUserGroups = pipe([ 84 | get(is(Array), 'groups'), 85 | maybe(authenticatedGroups, concat(authenticatedGroups)) 86 | ]); 87 | 88 | // getUserGroupsFromSession :: Either Error Session -> Array Group 89 | const getUserGroupsFromSession = either(K(unauthenticatedGroups), getUserGroups); 90 | 91 | //Export a middleware which determines the user session and attaches it to request.auth. 92 | module.exports = req => Future.do(function*() { 93 | 94 | // grants :: {String: Array String} 95 | const grants = yield req.services.config('permissions'); 96 | 97 | // groupsToPermissions :: Array Group -> Array String 98 | const groupsToPermissions = chain(group => grants[group] || []); 99 | 100 | // session :: Either Error Session 101 | const session = chain(req.services.auth.tokenToSession, getTokenFromRequest(req)); 102 | 103 | // groups :: Array Group 104 | const groups = getUserGroupsFromSession(session); 105 | 106 | // permissions :: Array String 107 | const permissions = groupsToPermissions(groups); 108 | 109 | // has :: String -> Boolean 110 | const has = x => mm.any(x, permissions); 111 | 112 | // guard -> Future NotAuthorizedError () 113 | const guard = x => Future((l, r) => has(x) ? r() : l(missingPermission(x))); 114 | 115 | req.auth = {session, groups, permissions, has, guard}; 116 | 117 | }); 118 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | ## General 4 | 5 | ### Errors 6 | 7 | All API errors will be in JSON format. An erroneous response will have the 8 | following shape: 9 | 10 | ```txt 11 | 400 Status Message 12 | ----- 13 | {"name": "NameOfError", "message": "Human-readable message"} 14 | ``` 15 | 16 | ### Providing an API version 17 | 18 | Any plain request to the API will tell you the following: 19 | 20 | ```json 21 | {"name":"BadRequestError","message":"No valid API version provided"} 22 | ``` 23 | 24 | This is because every client has to provide the version of the API they wish to 25 | use. This allows the API to make backwards-incompatible changes to certain 26 | end-points, whilst maintaining backwards compatibility with older clients. 27 | 28 | The API Version can be provided in two ways. The recommended way is **to send 29 | an `Api-Version` header**. The alternative is to set the `_apiv` query 30 | parameter. The header or query parameter has to contain a fully qualified 31 | [semantic version](http://semver.org/), for example: `Api-Version: 1.1.0`. The 32 | version corresponds to the version in `package.json`. 33 | 34 | ### Authentication 35 | 36 | #### `POST /auth` 37 | 38 | Request an authorization token pair: 39 | 40 | ```txt 41 | POST /auth 42 | Api-Version: 0.1.0 43 | Content-Type: application/json 44 | ----- 45 | {"username": "avaq", "password": "password123"} 46 | ``` 47 | 48 | ```txt 49 | 200 Ok 50 | Set-Cookie: token= 51 | ----- 52 | {"token": "", "refresh": ""} 53 | ``` 54 | 55 | The request might also fail, and the response will have code `400` in case the 56 | request was invalid (like invalid data format), or `401` if the credentials are 57 | invalid (eg. a wrong password or non-existent username). 58 | 59 | #### `GET /auth` 60 | 61 | Determine whether a token is still valid, or why it's not granting you access. 62 | 63 | ```txt 64 | GET /auth 65 | Api-Version: 0.1.0 66 | Authorization: Bearer: 67 | ``` 68 | 69 | ```txt 70 | 200 OK 71 | ----- 72 | { 73 | "authenticated": false, 74 | "reason": {"name": "TokenExpiredError", "message": "Token expired"} 75 | } 76 | ``` 77 | 78 | The response JSON contains an "authenticated" boolean, indicating whether the 79 | token proves authentication. In cases where the user is not authenticated a 80 | `reason` field will be present with an error explaining what went wrong. If the 81 | user *is* authenticated, then a `session` field will be present containing the 82 | token payload, for your convenience. 83 | 84 | Errors can be one of: 85 | 86 | * `InvalidTokenClaimsError` 87 | * `InvalidTokenTypeError` 88 | * `InvalidSessionTypeError` 89 | * `MissingAuthorizationHeaderError` 90 | * `MissingTokenCookieError` 91 | * `MalformedAuthorizationHeaderError` 92 | * `InvalidAuthorizationHeaderError` 93 | * `TokenExpiredError` 94 | 95 | ### `PUT /auth` 96 | 97 | Refreshes a token-pair. 98 | 99 | ```txt 100 | PUT /auth 101 | Api-Version: 0.1.0 102 | Content-Type: application/json 103 | ----- 104 | {"token": "", "refresh": ""} 105 | ``` 106 | 107 | ```txt 108 | 200 Ok 109 | Set-Cookie: token= 110 | ----- 111 | {"token": "", "refresh": ""} 112 | ``` 113 | 114 | ### Authorization 115 | 116 | The authorization token obtained by [authenticating](#authentication) can be 117 | included in any requests using the `Authorization: Bearer: `-header, or a cookie 118 | with a `token=`-field. The cookie only works for GET requests, and is intended 119 | to be a fall-back for when a resource is embedded inside an HTML page. 120 | 121 | When you are unauthorized to perform a certain requests, the response will 122 | have status code `400` if you provided an invalid token, status code `401` if 123 | your token does not authenticate you or `403` if you are authenticated but 124 | missing the required permissions. The possible error names in the JSON response 125 | are equal to those you get from requesting [`GET /auth`](#get-auth). 126 | -------------------------------------------------------------------------------- /test/integration/server.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const supertest = require('supertest'); 4 | const Future = require('fluture'); 5 | const {version} = require('../../package'); 6 | const {App, Middleware} = require('momi'); 7 | const {prop} = require('../../src/prelude'); 8 | 9 | const co = gen => Future.do(gen).promise(); 10 | const asyncTest = gen => () => co(gen); 11 | const send = request => Future.node(done => request.end(done)); 12 | 13 | const serverTests = services => describe('HTTP Server', () => { 14 | 15 | const req = supertest(services.app); 16 | 17 | it('responds to requests', asyncTest(function*() { 18 | const res = yield send(req.get('/').set('Api-Version', version)); 19 | yield Future.try(() => { 20 | expect(res.status).to.equal(200); 21 | }); 22 | })); 23 | 24 | describe('/auth', () => { 25 | 26 | let token; 27 | 28 | it('responds with 401 to a post request containing an invalid password', asyncTest(function*() { 29 | 30 | const res = yield send(req.post('/auth').set('Api-Version', version).send({ 31 | username: 'avaq', 32 | password: 'wrongPassword' 33 | })); 34 | 35 | yield Future.try(_ => { 36 | expect(res.status).to.equal(401); 37 | expect(res.body).to.be.an('object'); 38 | expect(res.body).to.have.property('name', 'UnauthorizedError'); 39 | expect(res.body).to.have.property('message', 'Invalid credentials'); 40 | }); 41 | 42 | })); 43 | 44 | it('sends me a token-pair when I post my username and password', asyncTest(function*() { 45 | 46 | const res = yield send(req.post('/auth').set('Api-Version', version).send({ 47 | username: 'avaq', 48 | password: 'password123' 49 | })); 50 | 51 | yield Future.try(_ => { 52 | expect(res.status).to.equal(200); 53 | expect(res.body).to.be.an('object'); 54 | expect(res.body).to.have.property('token'); 55 | expect(res.body).to.have.property('refresh'); 56 | }); 57 | 58 | token = res.body.token; 59 | 60 | })); 61 | 62 | it('authorizes me when I include the token in my headers', asyncTest(function*() { 63 | 64 | const res = yield send( 65 | req.get('/auth') 66 | .set('Api-Version', version) 67 | .set('Authorization', `Bearer: ${token}`) 68 | ); 69 | 70 | yield Future.try(_ => { 71 | expect(res.status).to.equal(200); 72 | expect(res.body).to.be.an('object'); 73 | expect(res.body).to.have.property('authenticated', true); 74 | expect(res.body).to.have.property('session'); 75 | expect(res.body.session).to.be.an('object'); 76 | expect(res.body.session).to.have.property('user', 'avaq'); 77 | expect(res.body.session).to.have.property('groups'); 78 | }); 79 | 80 | })); 81 | 82 | it('does not authorize me when the token is missing', asyncTest(function*() { 83 | 84 | const res = yield send( 85 | req.get('/auth') 86 | .set('Api-Version', version) 87 | ); 88 | 89 | yield Future.try(_ => { 90 | expect(res.status).to.equal(200); 91 | expect(res.body).to.be.an('object'); 92 | expect(res.body).to.have.property('authenticated', false); 93 | expect(res.body).to.have.property('reason'); 94 | expect(res.body.reason).to.be.an('object'); 95 | expect(res.body.reason).to.have.property('name', 'MissingAuthorizationHeaderError'); 96 | expect(res.body.reason).to.have.property('message'); 97 | }); 98 | 99 | })); 100 | 101 | }); 102 | 103 | }); 104 | 105 | before('bootstrap', function(run) { 106 | 107 | this.timeout(30000); 108 | 109 | const app = App.empty() 110 | .use(require('../../src/bootstrap/service')) 111 | .use(require('../../src/bootstrap/config')) 112 | .use(require('../../src/bootstrap/token')) 113 | .use(require('../../src/bootstrap/users')) 114 | .use(require('../../src/bootstrap/auth')) 115 | .use(require('../../src/bootstrap/app')) 116 | .use(_ => 117 | Middleware.get 118 | .map(prop('services')) 119 | .map(serverTests) 120 | .chain(_ => Middleware.lift(Future((rej, res) => { 121 | after('unbootstrap', res); 122 | run(); 123 | })))); 124 | 125 | App.run(app, null).fork( 126 | err => { 127 | console.error(err.stack || String(err)); //eslint-disable-line 128 | process.exit(1); 129 | }, 130 | done => done() 131 | ); 132 | 133 | }); 134 | 135 | //This is a work-around because --delay does not work with --require. 136 | describe('Setup', () => it('runs', () => {})); 137 | -------------------------------------------------------------------------------- /src/services/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const error = require('http-errors'); 4 | const Future = require('fluture'); 5 | const {randomString} = require('../util/hash'); 6 | const { 7 | eitherToFuture, 8 | pipe, 9 | get, 10 | Left, 11 | Right, 12 | maybeToEither, 13 | chain, 14 | curry4, 15 | equals, 16 | map, 17 | sort, 18 | is 19 | } = require('../prelude'); 20 | 21 | //data TokenType = Authorization | Refresh 22 | const Authorization = 1; 23 | const Refresh = 2; 24 | 25 | // tokenClaimKeys :: Array String 26 | const tokenClaimKeys = sort(['_', 't', '$', 'iat', 'exp']); 27 | 28 | // refreshClaimKeys :: Array String 29 | const refreshClaimKeys = sort(['_', 't', 'exp']); 30 | 31 | // invalidTokenClaims :: InvalidRequestError 32 | const invalidTokenClaims = error(400, { 33 | name: 'InvalidTokenClaimsError', 34 | message: 'Given token does not contain exactly the expected claims' 35 | }); 36 | 37 | // invalidRefreshClaims :: InvalidRequestError 38 | const invalidRefreshClaims = error(400, { 39 | name: 'InvalidRefreshClaimsError', 40 | message: 'Given refresh token does not contain exactly the expected claims' 41 | }); 42 | 43 | // invalidTokenType :: InvalidRequestError 44 | const invalidTokenType = error(400, { 45 | name: 'InvalidTokenTypeError', 46 | message: 'An invalid type of token was provided where an Authorization token was expected' 47 | }); 48 | 49 | // invalidRefreshType :: InvalidRequestError 50 | const invalidRefreshType = error(400, { 51 | name: 'InvalidRefreshTypeError', 52 | message: 'An invalid type of token was provided where a Refresh token was expected' 53 | }); 54 | 55 | // invalidSessionType :: InternalServerError 56 | const invalidSessionType = error(500, { 57 | name: 'InvalidSessionTypeError', 58 | message: 'Unexpected session data type' 59 | }); 60 | 61 | // tokenExpired :: NotAuthorizedError 62 | const tokenExpired = error(401, { 63 | name: 'TokenExpiredError', 64 | message: 'Token expired' 65 | }); 66 | 67 | // refreshExpired :: NotAuthorizedError 68 | const refreshExpired = error(401, { 69 | name: 'RefreshExpiredError', 70 | message: 'Token expired' 71 | }); 72 | 73 | // tokenNotExpired :: InvalidRequestError 74 | const tokenNotExpired = error(400, { 75 | name: 'TokenNotExpiredError', 76 | message: 'Token has not expired yet' 77 | }); 78 | 79 | // pairIdMismatch :: InvalidRequestError 80 | const pairIdMismatch = error(400, { 81 | name: 'PairIdMismatchError', 82 | message: 'Token pair identity mismatch' 83 | }); 84 | 85 | // isValidTokenClaims :: Object -> Boolean 86 | const isValidTokenClaims = pipe([Object.keys, sort, equals(tokenClaimKeys)]); 87 | 88 | // isValidRefreshClaims :: Object -> Boolean 89 | const isValidRefreshClaims = pipe([Object.keys, sort, equals(refreshClaimKeys)]); 90 | 91 | // validateTokenClaims :: Either Error Claims -> Either Error Claims 92 | const validateTokenClaims = chain(claims => 93 | isValidTokenClaims(claims) ? Right(claims) : Left(invalidTokenClaims) 94 | ); 95 | 96 | // createTokenPair :: Number -> Number -> (b -> Either Error a) -> b -> Future Error [a, a] 97 | exports.createTokenPair = 98 | curry4((tokenLife, refreshLife, encode, session) => Future.do(function*() { 99 | 100 | const id = yield randomString(16); 101 | 102 | const refreshClaims = { 103 | _: id, 104 | t: Refresh, 105 | exp: Date.now() + refreshLife 106 | }; 107 | 108 | const authClaims = { 109 | _: id, 110 | t: Authorization, 111 | $: session, 112 | iat: Date.now(), 113 | exp: Date.now() + tokenLife 114 | }; 115 | 116 | return yield Future.both( 117 | eitherToFuture(encode(authClaims)), 118 | eitherToFuture(encode(refreshClaims)) 119 | ); 120 | 121 | })); 122 | 123 | // tokenToSession :: Number -> (b -> Either Error a) -> TypeRep a -> b -> Either Error a 124 | exports.tokenToSession = curry4((tokenLife, decode, Type, token) => pipe([ 125 | decode, 126 | validateTokenClaims, 127 | chain(claims => 128 | claims.t !== Authorization 129 | ? Left(invalidTokenType) 130 | : claims.iat < (Date.now() - tokenLife) 131 | ? Left(tokenExpired) 132 | : Right(claims)), 133 | map(get(is(Type), '$')), 134 | chain(maybeToEither(invalidSessionType)) 135 | ], token)); 136 | 137 | // verifyTokenPair :: Number -> Number -> AuthorizationClaims -> RefreshClaims -> Session 138 | exports.verifyTokenPair = curry4((tokenLife, refreshLife, token, refresh) => 139 | !isValidTokenClaims(token) 140 | ? Left(invalidTokenClaims) 141 | : !isValidRefreshClaims(refresh) 142 | ? Left(invalidRefreshClaims) 143 | : token.t !== Authorization 144 | ? Left(invalidTokenType) 145 | : refresh.t !== Refresh 146 | ? Left(invalidRefreshType) 147 | : token._ !== refresh._ 148 | ? Left(pairIdMismatch) 149 | : token.iat > (Date.now() - tokenLife) 150 | ? Left(tokenNotExpired) 151 | : token.iat < (Date.now() - refreshLife) 152 | ? Left(refreshExpired) 153 | : Right(token.$) 154 | ); 155 | --------------------------------------------------------------------------------