Code coverage report for lib/signer.js

Statements: 100% (40 / 40)      Branches: 100% (28 / 28)      Functions: 100% (9 / 9)      Lines: 100% (40 / 40)      Ignored: none     

All files » lib/ » signer.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166            1       1                                       6 6   6 6                     32 3     29                                     11 1     10     10 10   10 9 9     10 10   10                       17                       12         12   12   12 3     8 2     6                           1 1   1   1     1       1 3   3   3 2   1        
/**
 * Returns a singleton
 *
 * @module signer
 */
 
var url         = require('url')
  , crypto      = require('crypto')
  , querystring = require('querystring');
 
module.exports = exports = {
  _algorithm: "sha256",
  _digest: "base64",
 
  _ttl: 3600, // seconds
 
  _privateKey: "",
 
  /**
   * Initialize the signer singleton with a privateKey and other options
   *
   * Must be called first with the privateKey before calling any other functions
   * 
   * @param {Object} options            The options object
   * @param {String} options.privateKey The private key to hash the URLs with
   * @param {String} [options.algorithm="sha256"]  The algorithm to pass to [crypto.createHash(algorithm)]{@link http://nodejs.org/api/crypto.html#crypto_crypto_createhash_algorithm}
   * @param {String} [options.digest="base64"]     The digest to pass to [crypto.digest(encoding)]{@link http://nodejs.org/api/crypto.html#crypto_hash_digest_encoding}
   * @param {Number} [options.ttl=3600]        Time to live in seconds for the URL after which it should expire; 0 for never
   */
  init: function(options) {
    this._algorithm = options.algorithm || this._algorithm;
    this._digest = options.digest || this._digest;
 
    this._ttl = Number(options.ttl) >= 0 ? Number(options.ttl) : this._ttl;
    this._privateKey = options.privateKey;
  },
 
  /**
   * Sign a given URL and return the signature
   * 
   * @param  {String} urlToSign URL to sign
   * @throws {Error}            If init() is not called first with a privateKey
   * @return {String}           URL signature
   */
  getUrlSignature: function(urlToSign) {
    if (!this._privateKey.length) {
      throw new Error("Call init() with privateKey first");
    }
 
    return crypto
        .createHmac(this._algorithm, this._privateKey)
        .update(urlToSign, 'utf-8')
        .digest(this._digest);
  },
 
  /**
   * Sign a given URL and return a new URL having two additional query params: `expires`, `signature`
   *
   * `expires` is TTL seconds ahead of the time at whoch the URL was signed
   *
   * If the given URL already has `expires` or `signature` set as query params, they will be
   * overridden.
   * 
   * @param  {String} urlToSign The URL to sign
   * @throws {Error}            If init() is not called first with a privateKey
   * @return {String}           The new URL with `expires` and `signature` set as query params
   */
  getSignedUrl: function(urlToSign) {
    if (!this._privateKey.length) {
      throw new Error("Call init() with privateKey first");
    }
 
    var parsedUrl = url.parse(urlToSign, true)
      , urlQuery  = parsedUrl.query;
 
    delete(urlQuery.expires);
    delete(urlQuery.signature);
 
    if (this._ttl) {
      urlQuery.expires = Math.floor(new Date()/1000) + this._ttl;
      parsedUrl.search = querystring.stringify(urlQuery);
    }
 
    urlQuery.signature = this.getUrlSignature(parsedUrl.format());
    parsedUrl.search = querystring.stringify(urlQuery);
 
    return parsedUrl.format();
  },
 
  /**
   * Checks if the signature of a given URL matches the expected signature
   * 
   * @param  {String}  signature   The expected signature
   * @param  {Strign}  urlToVerify The URL to check
   * @throws {Error}            If init() is not called first with a privateKey
   * @return {Boolean}             Whether or not the signature is valid
   */
  verifyUrlSignature: function(signature, urlToVerify) {
    return signature === this.getUrlSignature(urlToVerify);
  },
 
  /**
   * Verifies a given URL by checking first if the signature is valid and then checking it has
   * not expired
   * 
   * @param  {String}         urlToVerify The URL to verify
   * @throws {Error}            If init() is not called first with a privateKey
   * @return {Boolean|String}             true, `expired`, or `invalid`
   */
  verifySignedUrl: function(urlToVerify) {
    var parsedUrl = url.parse(urlToVerify, true)
      , urlQuery = parsedUrl.query
      , signature = urlQuery.signature
      , expires = urlQuery.expires;
 
      delete(urlQuery.signature);
 
      parsedUrl.search = querystring.stringify(urlQuery);
      
      if (!this.verifyUrlSignature(signature, parsedUrl.format())) {
        return 'invalid';
      }
      
      if (expires && Math.floor(new Date()/1000) > expires) {
        return 'expired';
      }
 
      return true;
  },
 
  /**
   * Return the Express middleware
   *
   * @param  {Object}   options
   * @param  {Function} [options.invalid=sends 403] The callback with signature fn(req, res) to
   *                                                call when the request URL is invalid
   * @param  {Function} [options.expired=sends 410] The callback with signature fn(req, res) to
   *                                                call when the request URL is expired
   * @return {Function} Express middleware function
   */
  verifier: function(options) {
    var self = this;
    options = options || {};
 
    var callbacks = {
      invalid: options.invalid || function(req, res) {
        res.sendStatus(403); // Forbidden
      },
      expired: options.expired || function(req, res) {
        res.sendStatus(410); // Gone
      }
    };
 
    return function(req, res, next) {
      var url = req.originalUrl;
 
      var valid = self.verifySignedUrl(url);
 
      if (valid !== true && callbacks[valid]) {
        callbacks[valid](req, res);
      } else {
        next();
      }
    };
  }
};