estate/alexa-router

View on GitHub
lib/router.js

Summary

Maintainability
B
4 hrs
Test Coverage
'use strict'

let _ = require('lodash')
let Joi = require('joi')
let url = require('url')
let path = require('path')
let request = require('request-promise')
let crypto = require('crypto')
let x509 = require('x509')

let helpers = require('./helpers')
let Request = require('./request')
let errors = require('./errors')

/**
 * The main AlexaRouter class
 */
class AlexaRouter {
  /**
   * Instantiates AlexaRouter and creates an empty routing table
   * @constructs AlexaRouter
   * @param {Object} config
   * @param {String[]} config.appId Your application ID or an array with many IDs
   * @param {Boolean} [config.routeIntentOnly=true] Only deal with intent requests
   * @param {Boolean} [config.verifySignature=true] Whenever to validate the request's signature
   * @param {Boolean} [config.verifyTimestamp=true] Whenever to validate the request's timestamp
   * @param {Boolean} [config.verifyApplication=true] Whenever to validate the request's application ID
   */
  constructor (config) {
    this.config = helpers.validate(config, Joi.object({
      appId: Joi.array().items(Joi.string()).single().required(),
      routeIntentOnly: Joi.bool().default(true),
      verifySignature: Joi.bool().default(true),
      verifyTimestamp: Joi.bool().default(true),
      verifyApplicationId: Joi.bool().default(true)
    }))

    this.actions = {}
    this.certs = {}
  }

  /**
   * Registers a new action into the router
   * @param {String} name The action's name
   * @param {Object} config
   * @param {Function} config.handler The main handler of the action
   * @param {Object} [config.global] Additional global configurations
   * @param {String} [config.global.type] The global type to be matched
   * @param {String} [config.global.intent] The intent name if any
   * @return {[type]} [description]
   */
  action (name, config) {
    helpers.checkDuplicatedAction(this, name)

    config = helpers.validate(config, Joi.object({
      handler: Joi.func().required(),
      global: Joi.object({
        type: Joi.only('launch', 'intent', 'sessionEnded', 'unexpected').required(),
        intent: Joi.when('type', {
          is: 'intent',
          then: Joi.string().required(),
          otherwise: Joi.forbidden()
        })
      }).optional()
    }))

    config.name = name
    helpers.checkConflictingGlobal(this, config)
    this.actions[name] = config
  }

  /**
   * Dispatches an Alexa request through the router
   * @param {Object} alexaData A valid Alexa request
   * @param {Object} headers The headers present in the incoming request
   * @return {Promise(Response)} A promise that resolves to a Response instance
   */
  dispatch (alexaData, headers) {
    return new Promise((resolve, reject) => {
      let request = new Request(alexaData, this)
      Promise.resolve(this._checkSignature(alexaData, headers))
      .then(() => {
        if (this.config.routeIntentOnly && request.type !== 'intent') {
          return {}
        } else {
          return this._actionDiscovery(request)()
        }
      })
      .then(resolve, reject)
    })
  }

  /**
   * Detects which action should be triggered
   * @private
   * @param {Request} request A Request instance
   * @return {Function} An handler ready to be executed
   * @throws {RoutingFailed} If not able to route a request
   */
  _actionDiscovery (request) {
    let next = request.next()

    if (next) {
      let search = { type: request.type }

      if (request.type === 'intent') {
        search.intent = request.intent.name
      }

      let action = _.find(next, search)

      if (action) {
        return this.actions[action.action].handler.bind(null, request, action.params)
      } else {
        let unexpected = _.find(next, { type: 'unexpected' })

        if (unexpected) {
          return this.actions[unexpected.action].handler.bind(null, request, unexpected.params)
        } else {
          let unexpected = _.find(this.actions, {
            global: { type: 'unexpected' }
          })

          if (unexpected) {
            return unexpected.handler.bind(null, request)
          }
        }
      }
    }

    let search = {
      global: { type: request.type }
    }

    if (request.type === 'intent') {
      search.global.intent = request.intent.name
    }

    let action = _.find(this.actions, search) || _.find(this.actions, {
      global: { type: 'unexpected' }
    })

    if (action) {
      return action.handler.bind(null, request)
    } else {
      throw new errors.RoutingFailed(request.id, request.type)
    }
  }

  /**
   * Check, retrieve and cache certificates from Amazon
   * @param {String} uri The certificate's URI
   * @throws {InvalidCertificateUri} If the URI is untrusted
   * @return {Promise(String)} A promise that resolves to the certificate
   */
  _retrieveCertificate (uri) {
    let uriData = url.parse(uri)

    return new Promise((resolve, reject) => {
      if (this.certs[uri]) {
        return resolve(this.certs[uri])
      } else if (uriData.protocol.toLowerCase() !== 'https:' ||
        uriData.hostname.toLowerCase() !== 's3.amazonaws.com' ||
        !path.normalize(uriData.path).startsWith('/echo.api/') ||
        (uriData.port !== null && uriData.port !== '443')) {
        reject(new errors.InvalidCertificateUri(uri))
      } else {
        this.certs[uri] = request.get(uri)
        .then(cert => this._checkCert(cert))

        return resolve(this.certs[uri])
      }
    })
  }

  /**
   * Check if the certificate is valid
   * @param {String} cert The certificate to be validated
   * @throws {InvalidCertificate} If cert is invalid
   * @return {String} The cert
   */
  _checkCert (cert) {
    let date = new Date()
    let data

    try {
      data = x509.parseCert(cert)
    } catch (_) {
      throw new errors.InvalidCertificate()
    }

    // TODO find a way to test this with a custom certificate
    /* $lab:coverage:off$ */
    if (new Date(data.notBefore) >= date ||
      new Date(data.notAfter) <= date ||
      data.altNames.indexOf('echo-api.amazon.com') === -1) {
      throw new errors.InvalidCertificate()
    } else {
      return cert
    }
    /* $lab:coverage:on$ */
  }

  /**
   * Check if the incoming request has a valid timestamp
   * @param {Object} payload The incoming payload
   * @throws {ExpiredRequest} If the request has an invalid timestamp
   */
  _checkTimestamp (payload) {
    let limit = new Date(new Date().getTime() - 15 * 1000)

    if (this.config.verifyTimestamp === true && new Date(payload.request.timestamp) < limit) {
      throw new errors.ExpiredRequest()
    }
  }

  /**
   * Check if the request has a valid application id
   * @param {Object} payload The incoming payload
   * @throws {InvalidApplicationId} If the request has an invalid application ID
   */
  _checkAppId (payload) {
    if (this.config.verifyApplicationId === true &&
      this.config.appId.indexOf(payload.session.application.applicationId) === -1) {
      throw new errors.InvalidApplicationId()
    }
  }

  /**
   * Check the signature of the incoming request to make sure it's from Amazon
   * @param {Object} payload The incoming payload
   * @param {Object} headers The headers present in the original request
   * @throws {InvalidSignature} If the request have an invalid signature
   * @return {Promise} A promise that resolves if the requests is valid
   */
  _checkSignature (payload, headers) {
    if (this.config.verifySignature === true) {
      return this._retrieveCertificate(headers['signaturecertchainurl'])
      .then(cert => {
        let verify = crypto.createVerify('RSA-SHA1')
        verify.write(JSON.stringify(payload))

        if (!verify.verify(new Buffer(cert), new Buffer(headers.signature, 'base64'))) {
          throw new errors.InvalidSignature()
        } else {
          this._checkTimestamp(payload)
          this._checkAppId(payload)
        }
      })
    } else {
      return Promise.resolve()
    }
  }
}

module.exports = AlexaRouter