Node Library designed to make building APIs that use HMAC signatures simple
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
src
usage
.eslintrc.json
.gitignore
LICENSE
README.md
index.js
package-lock.json
package.json

README.md

simple-hmac-auth

Node Library designed to make building an API that uses HMAC signatures simple.

Includes a server component, Express middleware, and a client.

Specification

For all incoming requests, the HTTP method, path, query string, headers and body should be signed with a secret and sent as the request's "signature." The headers should the user's API key, as well as a timestamp of when the request was made. On the server, the request is confirmed against the signature. If the signature does not match, the request is rejected. If the server receives a request with a timestamp older than five minutes, it is also rejected.

This enables three things:

  • Verify the authenticity of the client
  • Prevent MITM attack
  • Protect against replay attacks

The client's authenticity is confirmed by their continued ability to produce signatures based on their secret. This approach also prevents man-in-the-middle attacks because any tampering would result in the signature mismatching the request's contents. Finally, replay attacks are prevented because signed requests with old timestamps will be rejected.

Each request requires three headers: x-api-key, date, and authorization. If the HTTP request contains a body, the content-length and content-type headers are also required.

The x-api-key header should contain the string representation of the user's API key.

The date header is a standard RFC-822 (updated in RFC-1123) date, as per RFC-7231.

The authorization header is a standard as per RFC-2617 that, confusingly, is designed for authentication and not authorization. It contains a signature of the entire request.

To calculate the signature, the client first needs to create a string representation of the request. When the server recieves an authenticated request it computes the the signature and compares it with the signature provided by the client. Therefore, the client must create a string representation of the request in the exact same way as the server. This is called "canonicalization."

The format of a canonical representation of a request is:

     HTTP Verb + \n
     URI + \n
     Canonical query string + \n
     Canonically formatted signed headers + \n
     Hashed body payload

The canonical representations of these elements are as follows

Component Format Example
HTTP Verb upperCase(verb) POST, GET or DELETE
URI encode(uri) /items/test%20item
Query String encode(paramA) + '=' + encode(valueA) + '&' + encode(paramB) + '=' + encode(valueB) paramA=valueA&paramB=value%20B
Headers lowerCase(keyA) + ':' + trim(valueA) + '\n' + lowerCase(keyB) + ':' + trim(valueB) keyA:valueA
keyB:value%20B
Hashed payload hex(hash('sha256', bodyData)) ...

The HTTP verb must be upper case. The URI should be url-encoded. The query string elements should be alphabetically sorted. The header keys must all be lower case (as per RFC-2616) and alphabetically sorted. The only headers included in the signature should be: x-api-key, date, and optionally content-length and content-type if the HTTP body is not empty. The last line of the request string should be a hex representation of a SHA256 hash of the request body. If there is no request body, it should be the hash of an empty string.

Programatically:

     upperCase(method) + \n
     path + \n
     encode(paramA) + '=' + escape(valueA) + '&' + escape(paramB) + '=' + escape(valueB) + \n
     lowerCase('a-api-key') + ':' + trim(_API_KEY_) + \n + lowerCase(content-length) + ':' + trim('15') + \n
     hex(hash('sha256', bodyData)) + \n

For Example

     POST
     /items/test
     paramA=valueA&paraB=value%20B
     content-length:15
     date:Tue, 20 Apr 2016 18:48:24 GMT
     x-api-key:12345
     8eb2e35250a66c65d981393c74cead26a66c33c54c4d4a327c31d3e5f08b9e1b

Then the HMAC signature of the entire request is generated by signing it with the secret, as a hex representation:

const signature = hex(hmacSha256(secret, requestString))

That value is then sent as the contents of the authorization header, with the preceding value 'signature' as well as the algorithm used to generate the hmac signature.

headers[authorization] = 'signature sha256' + signature

Usage

Both a server and client are included, as is direct integration with Express.

Express Server

const express = require('express');
const SimpleHMACAuth = require('simple-hmac-auth');

const secretForAPIKey = {
  API_KEY_ONE: 'SECRET_ONE',
  API_KEY_TWO: 'SECRET_TWO'
};

const app = express();

// Required. Execute callback with either an error, or an API key.
const secretForKey = (apiKey, callback) => {

  if (secretForAPIKey.hasOwnProperty(apiKey)) {

    callback(null, secretForAPIKey[apiKey]);
    return;
  }

  callback();
};

// Required. Handle requests that have failed authentication.
const onRejected = (error, request, response, next) => {

  console.log(`Authentication failed`, error);

  response.status(401).json({
    error: error
  });
};

// Optional. Log requests that have passed authentication.
const onAccepted = (request, response) => {
  console.log(`Authentication succeeded for request with API key "${request.apiKey}" and signature: "${request.signature}"`);
};

// Register authentication middleware 
// Include which body-parser modules to parse the request data with
// Specifying 'true' instead of an options dictionary will use defaults
app.use(SimpleHMACAuth.middleware({

  // Required
  secretForKey: secretForKey,
  onRejected: onRejected, 

  // Optional
  onAccepted: onAccepted,

  // Body-parser options. All optional.
  json: true,
  urlencoded: { extended: true, limit: '10mb' },
  text: { type: 'application/octet-stream' }
}));

// Set up routes
app.all('*', (request, response) => {
  console.log(`Routing request: ${request.method} ${request.url}`);
  response.status(200).end('200');
});

// Start the server
app.listen(80, () => {
  console.log(`Listening!`);
});

HTTP Server

const http = require('http');
const SimpleHMACAuth = require('simple-hmac-auth');

const secretForAPIKey = {
  API_KEY_ONE: 'SECRET_ONE',
  API_KEY_TWO: 'SECRET_TWO'
};

const auth = new SimpleHMACAuth.Server();

// Required. Execute callback with either an error, or an API key.
auth.secretForKey = (apiKey, callback) => {

  if (secretForAPIKey.hasOwnProperty(apiKey)) {

    callback(null, secretForAPIKey[apiKey]);
    return;
  }

  callback();
};

// Create HTTP server
http.createServer((request, response) => {

  let data = '';

  request.on('data', chunk => { 
    data += chunk.toString();
  });

  request.on('end', async () => {

    console.log(`Got request ${request.method} ${request.url}`);

    try {

      const { apiKey, signature } = await auth.authenticate(request, data);

      console.log(`Authentication passed for request with API key "${apiKey}" and signature "${signature}".`);

      response.writeHead(200);
      response.end('200');

    } catch (error) {

      console.log(`  Authentication failed`, error);

      response.writeHead(401, {'content-type': 'application/json'});
      response.end(JSON.stringify({error}));
    }

  });

}).listen(8000);

Client Implementation

Client Class

A JavaScript client that implements HMAC signing is included. Although the server component supports any type of input data, this client is specifically created to support JSON APIs. To point it to your service, instantiate it with your host, port, and if you've enabled SSL yet.

const SimpleHMACAuth = require('simple-hmac-auth');

const client = new SimpleHMACAuth.Client('API_KEY', 'SECRET', {
  host: 'localhost',
  port: 8000,
  ssl: false
});

Set up the request options

const options = {
  method: 'POST',
  path: '/items/',
  query: {
    string: 'string',
    boolean: true,
    number: 42,
    object: { populated: true },
    array: [ 1, 2, 3 ]
  },
  data: {
    string: 'string',
    boolean: true,
    number: 42,
    object: { populated: true },
    array: [ 1, 2, 3 ]
  }
};

It returns a promise, but will execute a callback if provided with one.

client.request(options, (error, results) => {

  if (error) {
    console.error(`Error:`, error);
    return;
  }

  console.log(results);
});
client.request(options).then(results => {
  
  console.log(results);
  
}).catch(error => {
  
  console.log(`Received error:`, error);
});
try {

  const results = await client.request(options);
  
  console.log(results);

} catch (error) {

  console.log('Error:', error);
}

Client Subclass

To write a client for your service, simply extend the class and add functions that match your API routes.

const SimpleHMACAuth = require('simple-hmac-auth');

class SampleClient extends SimpleHMACAuth.Client {

  constructor(apiKey, secret, settings) {
    super(apiKey, secret, settings);
    
    self.settings.host = 'api.myservice.com';
    self.settings.port = 443;
    self.settings.ssl = true;
  }

  create(data, callback) {
    return this.call('POST', '/items/', data, undefined, callback);
  }

  detail(id, parameters, callback) {
    return this.call('GET', '/items/' + encodeURIComponent(id), undefined, parameters, callback);
  }

  query(parameters, callback) {
    return this.call('GET', '/items/', undefined, parameters, callback);
  }

  update(id, data, callback) {
    return this.call('POST', '/items/' + encodeURIComponent(id), data, undefined, callback);
  }

  delete(id, callback) {
    return this.call('DELETE', '/items/' + encodeURIComponent(id), undefined, undefined, callback);
  }
}

module.exports = SampleClient;

Because this client's constructor specified the host, port, and SSL status of the service, it can be instantiated without any parameters beyond apiKey and secret.

const client = new SampleClient(apiKey, secret);

Just like it's parent class, the subclass implements both promises and callbacks.

const query = {
  string: 'string',
  boolean: true,
  number: 42
};
try {

  const results = await client.query(query);
  
  console.log(results);

} catch (error) {

  console.log('Error:', error);
}
client.query(query, (error, results) => {

  if (error) {
    console.log('Error:', error);
    return;
  }

  console.log(results);
});

Additional Implementations

Compatible simple-hmac-auth clients for iOS and PHP have also been implemented