TNOCS/node-auth

View on GitHub
src/lib/node-auth.ts

Summary

Maintainability
B
6 hrs
Test Coverage
import { UNAUTHORIZED } from 'http-status-codes';
import { Request, Response, NextFunction} from 'express';
// See also https://scotch.io/tutorials/authenticate-a-node-js-api-with-json-web-tokens
// import { Application, Response, NextFunction, Router, Request } from 'express';
import * as express from 'express';
// import { Request as _Request } from '~express/lib/request';
// import { Response as _Response } from '~express/lib/response';
// import { NextFunction as _NextFunction } from '~express/lib/router/index';
import * as jwt from 'jsonwebtoken';
import * as userRoute from './routes/user';
import * as loginRoute from './routes/login';
import * as verifyRoute from './routes/verify';
import * as authzRoute from './routes/authorize';
import { INodeAuthOptions } from './models/options';
// import { PolicySet } from './models/policy';
// import { initPolicyStore } from '../lib/authorize/policy-store';
import { initPEP, PolicyEnforcementPoint } from '../lib/authorize/pep';
// import { PolicyStore } from '../lib/authorize/policy-store';

/**
 * Return a function that authenticates the user, and sets the User details as req['user']: IUser (without pwd).
 *
 * @param {string} secretKey
 * @returns
 */
function authenticateUser(secretKey: string, blockUnauthenticatedUser = true) {
  const authnErrorHandler = blockUnauthenticatedUser
    ? (req: express.Request, res: express.Response, next: express.NextFunction, msg?: string) => {
      // AuthN failed, so return an error.
      res.status(UNAUTHORIZED).json({
        success: false,
        message: msg
      });
    }
    : (req: express.Request, res: express.Response, next: express.NextFunction, msg?: string) => {
      // Do not block, delete the user request object, if any, and continue
      delete req['user'];
      next();
    };

  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    // check header or url parameters or post parameters for token
    const token = userRoute.getToken(req);
    if (!token) {
      authnErrorHandler(req, res, next, 'No token provided');
    } else {
      // decode token: verifies secret and checks exp
      jwt.verify(token, secretKey, (err, user) => {
        if (err) {
          authnErrorHandler(req, res, next, 'Failed to authenticate token.');
          // res.json({ success: false, message: 'Failed to authenticate token.' });
        } else {
          // console.log(JSON.stringify(user, null, 2));
          // if everything is good, save to request for use in other routes
          req['user'] = user;
          next();
        }
      });
    }
  };
}

function getRoute(route: string | boolean, defaultRoute: string) {
  if (typeof route === 'string') {
    return route;
  } else {
    return (route == null || route) ? defaultRoute : null;
  }
}

/**
 * Small piece of documentation of the API, available at '/api'.
 *
 * @param {Router} apiRoutes
 * @param {INodeAuthOptions} options
 */
function createApiRoute(apiRoutes: express.Router, options: INodeAuthOptions) {
  let routes = [];

  const apiRoute = (options.api && typeof options.api === 'string') ? options.api : '/api';

  const login = getRoute(options.login, '/login');
  if (login) {
    routes.push({ route: `${apiRoute}${login}`, message: 'POST: Login route, post email and password, returns JSON web token.' });
  }

  const signupRoute = getRoute(options.signup, '/signup');
  if (signupRoute) {
    routes.push({ route: `${apiRoute}${signupRoute}`, message: 'POST: Signup route, post email and password, and optionally, first and name.' });
  }

  const verificationRoute = getRoute(options.verify && options.verify.route, '/activate');
  if (verificationRoute) {
    routes.push({ route: `${apiRoute}${verificationRoute}?email=[EMAIL]`, message: 'GET: Activation route to resend your activation email.' });
    routes.push({ route: `${apiRoute}${verificationRoute}/[ID]?t=[TOKEN]`, message: 'GET: Activation route to activate your account' });
  }

  const profileRoute = getRoute(options.profile, '/profile');
  if (profileRoute) {
    routes.push({ route: `${apiRoute}${profileRoute}`, message: 'GET: Returns your profile.' });
    routes.push({ route: `${apiRoute}${profileRoute}`, message: 'PUT: Updates your profile, you can send first, name, email, and password.' });
    routes.push({ route: `${apiRoute}${profileRoute}`, message: 'DELETE: Deletes your profile.' });
  }

  const authorizationRoute = getRoute(options.authorizations, '/authorizations');
  if (authorizationRoute) {
    routes.push({ route: `${apiRoute}${authorizationRoute}`, message: 'GET: Authorization route to get a user\'s privileges.' });
  }

  apiRoutes.get('/', (req: express.Request, res: express.Response) => {
    res.json(routes);
  });
}

function createRoutes(secretKey: string, options: INodeAuthOptions) {
  const apiRoutes = express.Router();
  loginRoute.init(options);
  userRoute.init(options);
  verifyRoute.init(options);
  authzRoute.init(options);

  createApiRoute(apiRoutes, options);

  const login = getRoute(options.login, '/login');
  if (login) {
    apiRoutes.route(login)
      .post(loginRoute.login);
  }

  const signupRoute = getRoute(options.signup, '/signup');
  if (signupRoute) {
    apiRoutes.route(signupRoute)
      .post(userRoute.signupUser);
  }

  const verificationRoute = getRoute(options.verify && options.verify.route, '/activate');
  if (verificationRoute) {
    apiRoutes.route(`${verificationRoute}`)
      .get(verifyRoute.resendEmail);

    apiRoutes.route(`${verificationRoute}/:id`)
      .get(verifyRoute.verifyEmail);
  }

  // From hence on forward, requires authentication

  apiRoutes.use(authenticateUser(secretKey, true)); // Always block non-authenticated users for all API calls

  const profileRoute = getRoute(options.profile, '/profile');
  if (profileRoute) {
    apiRoutes.route(profileRoute)
      .get(userRoute.getProfile)
      .put(userRoute.updateProfile)
      .delete(userRoute.deleteProfile);
  }

  const authorizationRoute = getRoute(options.authorizations, '/authorizations');
  if (authorizationRoute) {
    const cleanupJSON = (req: Request, res: Response, next: NextFunction) => {
        // In case the body is not properly send as JSON, correct some issues.
        if (req.body.hasOwnProperty('action') && typeof req.body['action'] === 'string') { req.body['action'] = +req.body['action']; }
        if (req.body.hasOwnProperty('decision') && typeof req.body['decision'] === 'string') { req.body['decision'] = +req.body['decision']; }
        next();
    };
    apiRoutes.route(authorizationRoute)
      .all(cleanupJSON)
      .get(authzRoute.getSubjectPrivileges)
      .put(authzRoute.updatePrivileges)
      .delete(authzRoute.deletePrivileges)
      .post(authzRoute.createPrivileges);

    const resource2JSON = (req: Request, res: Response, next: NextFunction) => {
        req.body = { resource: req.query };
        next();
    };
    apiRoutes.route(`${authorizationRoute}/resources`)
      .get(resource2JSON)
      .get(authzRoute.getResourcePrivileges);
  }

  const usersRoute = getRoute(options.users, '/users');
  if (usersRoute) {
    apiRoutes.route(usersRoute)
      .get(userRoute.listUsers)
      .post(userRoute.createUser);

    apiRoutes.route(`${usersRoute}/:id`)
      .get(userRoute.getUser)
      .delete(userRoute.deleteUser)
      .put(userRoute.updateUser);
  }

  return apiRoutes;
}

/**
 * External interface
 *
 * See also: http://stackoverflow.com/questions/12766528/build-a-function-object-with-properties-in-typescript
 */
export interface NodeAuth {
  (req: express.Request, res: express.Response, next: express.NextFunction): void;
  pep?: PolicyEnforcementPoint;
}
/**
 * Main function is returned to the express app.
 * It sets up the API routes:
 * - Public routes to login and get an overview of the API
 * - All routes created after calling it are being authenticated, i.e. the request will contain a user object.
 *
 * @export
 * @param {Application} app
 * @param {INodeAuthOptions} options
 * @returns
 */
export function nodeAuth(app: express.Application, options: INodeAuthOptions) {
  const secretKey = options.secretKey || app.get('secretKey');
  if (secretKey === null) { throw new Error('secretKey must be set'); }

  const apiRoute = getRoute(options.api, '/api');

  // apply the routes to our application with the prefix /api
  app.use(apiRoute, createRoutes(secretKey, options));
  const auth = <NodeAuth> authenticateUser(secretKey, options.blockUnauthenticatedUser);
  if (options.policyStore) {
    auth.pep = initPEP(authzRoute.policyStore);
  }
  return auth;
}