Source: protector.js

const googleapis = require('googleapis')
const jwt = require('jsonwebtoken')
const fs = require('fs')
const mongodb = require('mongodb')

const OAuth2 = googleapis.auth.OAuth2
const ObjectID = mongodb.ObjectID

/**
Configuration of trean protector

@memberof module:protector
@typedef {object} TreantConfig
@property {module:mongodb.MongoClient} db - Instance of connection to mongodb
@property {string} gClientId - Identify of application of google
@property {string} gSecrectId - Identify of application of google
@property {string} gRedirectUrl - Address which call after google authenticate
successfully
@property {string[]} gScopes - Define information which can retrieve from
google after get access token
@property {string} privateKeyPath - Path to PEM private file. It use to
encrypt/decrypt credential as json web token. It must absolute path to ensure
that path to key file is correct
@property {number} [tokenTimeout=1800] - Peroid token is valid in seconds
@example
var config = {
  db: dbclient,
  gClientId: '327834646153-27e424bo1lpofea0lkfs8bvrf7sv5j2e.apps.googleusercontent.com',
  gClientSecret: 'y7RCBAr4AT26oACJBJUSJIxv',
  gRedirectUrl: 'http://localhost:9001/v1/oauth/login',
  gScopes: [
    'https://www.googleapis.com/auth/plus.me',
    'profile',
    'email'
  ],
  privateKeyPath: '/usr/lib/node_module/gwisp/gwisp.key',
  tokenTimeout: 1800
}
*/

/**
- Authenticate service help authenticate user and authorize them permission
- It also support generate credential as json web token

@memberof module:protector
@class Treant
@constructor
@param {module:protector.TreantConfig} config - Configuration of treant protector
@example
var treant = new Treant({
  db: dbclient,
  gClientId: '327834646153-27e424bo1lpofea0lkfs8bvrf7sv5j2e.apps.googleusercontent.com',
  gClientSecret: 'y7RCBAr4AT26oACJBJUSJIxv',
  gRedirectUrl: 'http://localhost:9001/v1/oauth/login',
  gScopes: [
    'https://www.googleapis.com/auth/plus.me',
    'profile',
    'email'
  ],
  privateKeyPath: '/usr/lib/node_module/gwisp/gwisp.key',
  tokenTimeout: 1800
})
*/
function Treant (config) {
  /**
  Treant configuration

  @member {module:protector.TreanConfig}
  */
  this._config = config

  this._accountCol = config.db.collection('account')

  this._cert = fs.readFileSync(config.privateKeyPath)
}

const prototype = Treant.prototype

/**
Create new google oauth 2 object

@memberof module:protector.Treant
@return {module:googleapis.OAuth2}
@example
var oauth = this._gOauth()
*/
prototype._gOauth = function () {
  return new OAuth2(this._config.gClientId, this._config.gClientSecret,
    this._config.gRedirectUrl)
}

/**
- Get login url of oauth 2 service from google
- Then caller can perform GET http with url to require login with google

```bash
$ curl <gLoginUrl>
```

@memberof module:protector.Treant
@return {string}
@example
// see constructor for detail
var treant = new Treant(config)

var url = treant.gLoginUrl()

// url: https://accounts.google.com/o/oauth2/auth?scope=https%3A%2F%2Fwww.
// googleapis.com%2Fauth%2Fplus.me%20profile%20email&approval_prompt=
// auto&access_type=offline&response_type=code&client_id=736160870024-
// 0cu341if9scqjfpvdsaqhutjebobjjq0.apps.googleusercontent.com&redirect_uri=
// http%3A%2F%2Flocalhost%3A9001%2Foauth%2Flogin
*/
prototype.gLoginUrl = function () {
  const self = this
  var gOauth = self._gOauth()

  return gOauth.generateAuthUrl({
    scope: self._config.gScopes,
    approval_prompt: 'auto',
    access_type: 'offline'
  })
}

/**
Get account information from google

@memberof module:protector.Treant
@param {string} accessToken - Access token of google oauth 2
@param {StdCallback} callback
- Function will be call after done
- Result is object contains account information from google
*/
prototype.gAccount = function (accessToken, callback) {
  var plus = googleapis.plus('v1')
  var credentials = {access_token: accessToken}
  var gOauth = this._gOauth()

  gOauth.setCredentials(credentials)
  plus.people.get({userId: 'me', auth: gOauth}, function (err, account) {
    callback(err, account)
  })
}

/**
- Login with code from google
- Function will perform exhange code to get token
- If account is exist, create new local token and give back
- If account is not exist, create new account in database, set default setting.
Then create local token and give back

@memberof module:protector.Treant
@param {string} code - Code to exchange token from google
@param {StdCallback}
- Function will be call after done
- Result is local token
@example
// see constructor for detail
var treant = new Treant(config)

treant.gLoginCode('1234567890abcdef', function(err, token) {
  if (err) return

  // do some thing with token here
})
*/
prototype.gLoginCode = function (code, callback) {
  const self = this

  var gOauth = self._gOauth()
  gOauth.getToken(code, getToken)

  function getToken (err, tokens) {
    if (err) return callback(err)

    // decode token for google account information
    try {
      tokens.id_token = jwt.decode(tokens.id_token)
    } catch (e) {
      return callback(e)
    }

    // create new account into database if it not exist
    // if exist, do nothing
    verifyAccount(tokens, callback)
  }

  function verifyAccount (gToken, callback) {
    var filter = {issuer: gToken.id_token.iss, subject: gToken.id_token.sub}

    self._accountCol.findOne(filter, function (err, account) {
      if (err) return callback(err)

      if (account) return createToken(gToken, account, callback)

      createAccount(gToken, callback)
    })
  }

  function createToken (gToken, account, callback) {
    var tokenData = {
      id: account._id,
      g_access_token: gToken.access_token,
      g_refresh_token: gToken.refresh_token
    }
    var options = {expiresIn: self._config.tokenTimeout}

    jwt.sign(tokenData, self._cert, options, function (err, token) {
      if (err) return callback(err)

      callback(err, token)
    })
  }

  function createAccount (gToken, callback) {
    self.gAccount(gToken.access_token, function (err, gAccount) {
      if (err) return callback(err)

      var account = {
        issuer: gToken.id_token.iss,
        subject: gToken.id_token.sub,
        language: gAccount.language,
        name: gAccount.displayName,
        groups: [],
        schedulers: []
      }

      self._accountCol.insertOne(account, function (err) {
        if (err) return callback(err)

        createToken(gToken, account, callback)
      })
    })
  }
}

/**
Identify account from token

@memberof module:protector.Treant
@param {string} token - Token from client
@param {boolean} getInfo - Decide query database to retrieve account or not
@param {StdCallback} callback
- Function will be call after done, result is object
- Result contains field `token`. This is token is decoded
- If getInfo is true, result contains field `account` information
@example
// see constructor for detail
var treant = new Treant(config)

treant.identity(token, true, function(err, res) {
  if (err) return

  // do some thing with account here
  var account = res.account

  // do some thing with token decoded here
  var token = res.token
})
*/
prototype.identify = function (token, getInfo, callback) {
  const self = this

  jwt.verify(token, self._cert, function (err, decoded) {
    if (err) return callback(err)

    if (!getInfo) return callback(null, {token: decoded})

    self._accountCol.findOne({_id: ObjectID(decoded.id)}, function (err, acc) {
      callback(err, {token: decoded, account: acc})
    })
  })
}

/**
Options to authorize

@memberof module:protector
@typedef {object} AuthOptions
@property {boolean} [getAcc=false] - if true, retrieve account from database
and assign to req.account
@property {string} [group=null]
Require account must in specify group

If this field is null, no verify group of account
@property {string} [msg401=must login to perform this action]
Message responses to endpoint if authenticate is not successful
@property {string} [msg403=must have correct permission to perform this action]
Message responses to endpoint if authorize is not successful
@example
var authOptions = {
  group: 'root',
  msg403: 'must have root permission to perform this action'
}
*/

/**
This function return an express middleware allow authenticate or authorize
depend on options

- If authorize successful, continue next route
- If authorize fails, response message to endpoint and terminate

@memberof module:protector.Treant
@param {module:protector.AuthOptions} [options=null]
Options for authorization

If parameter is not specify, use default options
@return {module:express.Middleware}
@example
const express = require('express')

// see constructor for detail
const treant = new Treant(config)

const app = express()

app.use('/root', treant.inspect(group: 'root'), function(req, res) {
  // if this code block is reached, account in root group is logged in
  // if this code block is not reached, no account in root group is logged in
  var rootAccount = req.account

  // do some thing here
})

app.use('/account', treant.inspect(), function(req, res) {
  // if this code block is reach, an account is logged in
  // if this code block is not reached, no account is logged in
  var account = req.account

  // do some thing here
})
*/
prototype.check = function (options) {
  const self = this

  if (!options) options = {}

  var getAcc = options && options.getAcc
  var group = options && options.group

  if (!options.msg401) options.msg401 = 'must login to perform this action'
  if (!options.msg403) {
    options.msg403 = 'must have correct permission to perform this action'
  }

  return function (req, res, next) {
    var token = req.headers['authorization']

    if (!token) return res.status(401).json({message: options.msg401})

    self.identify(token, getAcc, function (err, ires) {
      if (err) return res.status(500).json(err)

      if (!ires.account) {
        return res.status(500).json({
          message: 'can not find account in database'
        })
      }

      if (getAcc) req.account = ires.account
      req.token = ires.token

      if (group) {
        if (ires.account.groups.indexOf(group) < 0) {
          return res.status(403).json({message: options.msg_403})
        }
      }

      next()
    })
  }
}

/**
Contains method to authenticate and authorize

@exports protector
@author kevin leptons <kevin.leptons@gmail.com>
@example
const protector = require('./lib/protector')

var treant = protector({
  db: dbclient,
  gClientId: '327834646153-27e424bo1lpofea0lkfs8bvrf7sv5j2e.apps.googleusercontent.com',
  gClientSecret: 'y7RCBAr4AT26oACJBJUSJIxv',
  gRedirectUrl: 'http://localhost:9001/v1/oauth/login',
  gScopes: [
    'https://www.googleapis.com/auth/plus.me',
    'profile',
    'email'
  ],
  privateKeyPath: '/usr/lib/node_module/gwisp/gwisp.key',
  tokenTimeout: 1800
})
*/
module.exports = function (config) {
  return new Treant(config)
}