From 15fba35eab0708cae6cea7c2eee02c7e8541ffac Mon Sep 17 00:00:00 2001 From: Jesse Youngblood Date: Sun, 21 Aug 2022 14:26:03 -0400 Subject: [PATCH] Support `timestamp` header in addition to `date` https://github.com/jessety/simple-hmac-auth/issues/7 --- README.md | 4 ++-- examples/server/index.js | 2 +- src/Client.ts | 14 +++++++++++-- src/Server.ts | 10 +++++---- src/canonicalize.ts | 7 ++++--- test/Client.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++ test/Server.test.js | 2 +- 7 files changed, 70 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3cafe5d..f84417a 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ Request signatures are designed to be used in conjunction with HTTPS. ### Headers -Each request requires three headers: `date`, `authorization` and `signature`. If the HTTP request contains a body, the `content-length` and `content-type` headers are also required. +Each request requires three headers: `authorization`, `signature` and either `date` or `timestamp`. If the HTTP request contains a body, the `content-length` and `content-type` headers are also required. -The `date` header is a standard [RFC-822 (updated in RFC-1123)](https://tools.ietf.org/html/rfc822#section-5) date, as per [RFC-7231](https://tools.ietf.org/html/rfc7231#section-7.1.1.2). +The `date` header is a standard [RFC-822 (updated in RFC-1123)](https://tools.ietf.org/html/rfc822#section-5) date, as per [RFC-7231](https://tools.ietf.org/html/rfc7231#section-7.1.1.2). Because it [cannot be programmatically set inside of a browser](https://fetch.spec.whatwg.org/#forbidden-header-name), the `timestamp` header may be substituted instead. The `authorization` header is a standard as per [RFC-2617](https://tools.ietf.org/html/rfc2617#section-3.2.2) that, confusingly, is designed for authentication and not authorization. It should contain a string representation of the client's API key. diff --git a/examples/server/index.js b/examples/server/index.js index e40e5e8..5a918bf 100644 --- a/examples/server/index.js +++ b/examples/server/index.js @@ -12,7 +12,7 @@ const http = require('http'); const SimpleHMACAuth = require('../../'); const settings = { - port: 8080, + port: 8000, secretsForAPIKeys: { API_KEY: 'SECRET', API_KEY_TWO: 'SECRET_TWO', diff --git a/src/Client.ts b/src/Client.ts index 278006d..31fd924 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -17,8 +17,9 @@ interface ClientSettings { maxSockets?: number host?: string port?: number - ssl?: boolean, + ssl?: boolean algorithm?: string + useDateHeader?: boolean headers?: {[key: string]: string} options?: http.RequestOptions | https.RequestOptions } @@ -119,6 +120,10 @@ class Client { validatedSettings.maxSockets = 250; } + if (typeof validatedSettings.useDateHeader !== 'boolean') { + validatedSettings.useDateHeader = false; + } + if (typeof validatedSettings.headers !== 'object') { validatedSettings.headers = {}; } @@ -246,7 +251,12 @@ class Client { } headers.authorization = `api-key ${apiKey}`; - headers.date = new Date().toUTCString(); + + if (this._settings.useDateHeader === true) { + headers.date = new Date().toUTCString(); + } else { + headers.timestamp = new Date().toUTCString(); + } // Sort query keys alphabetically const keys = Object.keys(query).sort(); diff --git a/src/Server.ts b/src/Server.ts index e3c99cf..1bf92e0 100644 --- a/src/Server.ts +++ b/src/Server.ts @@ -146,19 +146,21 @@ class SimpleHMACAuth { throw new AuthError(`Missing signature. Please sign all incoming requests with the 'signature' header.`, `SIGNATURE_HEADER_MISSING`); } - if (request.headers.date === undefined) { + const timestamp = request.headers.date ?? request.headers.timestamp; - throw new AuthError(`Missing timestamp. Please timestamp all incoming requests by including 'date' header.`, `DATE_HEADER_MISSING`); + if (timestamp === undefined) { + + throw new AuthError(`Missing timestamp. Please timestamp all incoming requests by including either the 'date' or 'timestamp' header.`, `DATE_HEADER_MISSING`); } // First, confirm that the 'date' header is recent enough - const requestTime = new Date(request.headers.date); + const requestTime = new Date(timestamp); const now = new Date(); // If this request was made over [60] seconds ago, ignore it if ((now.getTime() / 1000) - (requestTime.getTime() / 1000) > (this.options.permittedTimestampSkew / 1000)) { - const error = new AuthError(`Timestamp is too old. Recieved: "${request.headers.date}" current time: "${now.toUTCString()}"`, `DATE_HEADER_INVALID`); + const error = new AuthError(`Timestamp is too old. Received: "${request.headers.date}" current time: "${now.toUTCString()}"`, `DATE_HEADER_INVALID`); error.time = now.toUTCString(); throw error; diff --git a/src/canonicalize.ts b/src/canonicalize.ts index 736cfed..436164a 100644 --- a/src/canonicalize.ts +++ b/src/canonicalize.ts @@ -9,6 +9,7 @@ import crypto from 'crypto'; // Only sign these headers export const headerWhitelist = [ 'authorization', + 'timestamp', 'date', 'content-length', 'content-type' @@ -25,7 +26,7 @@ export const headerWhitelist = [ */ export function canonicalize(method: string, uri: string, queryString = '', headers: {[key: string]: string}, data?: string): string { - // Hash the method, the path, aplhabetically sorted headers, alphabetically sorted GET parameters, and body data + // Hash the method, the path, alphabetically sorted headers, alphabetically sorted GET parameters, and body data method = method.toUpperCase(); @@ -61,7 +62,7 @@ export function canonicalize(method: string, uri: string, queryString = '', head // Sort the header keys alphabetically headerKeys.sort(); - // Create a string of all headers, arranged alphabetically, seperated by newlines + // Create a string of all headers, arranged alphabetically, separated by newlines let headerString = ''; for (const [ index, key ] of headerKeys.entries()) { @@ -87,7 +88,7 @@ export function canonicalize(method: string, uri: string, queryString = '', head method + \n URL + \n Alphabetically sorted query string with individually escaped keys and values + \n - Alphabetically sorted headers with lower case keys, seperated by newlines + \n + Alphabetically sorted headers with lower case keys, separated by newlines + \n Hash of body, or hash of blank string if body is empty Or: diff --git a/test/Client.test.ts b/test/Client.test.ts index 282072c..cb7f461 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -14,6 +14,7 @@ describe('Client class', () => { expect(client._settings.timeout).toBe(7500); expect(client._settings.maxSockets).toBe(250); expect(client.agent.maxSockets).toBe(250); + expect(client._settings.useDateHeader).toBe(false); expect(client._settings.headers).toEqual({}); expect(client._settings.options).toEqual({}); expect(client._settings.verbose).toBe(false); @@ -27,6 +28,7 @@ describe('Client class', () => { algorithm: 'sha512', timeout: 30 * 1000, maxSockets: 500, + useDateHeader: true, headers: { 'x-custom-header': 'custom-value' }, options: { timeout: 100 @@ -41,6 +43,7 @@ describe('Client class', () => { expect(client._settings.timeout).toBe(30000); expect(client._settings.maxSockets).toBe(500); expect(client.agent.maxSockets).toBe(500); + expect(client._settings.useDateHeader).toBe(true); expect(client._settings.headers).toEqual({ 'x-custom-header': 'custom-value' }); expect(client._settings.options).toEqual({ timeout: 100 }); expect(client._settings.verbose).toBe(true); @@ -209,6 +212,47 @@ describe('Client class', () => { server.close(); }); + test('sends requests with the correct date header when specified', async () => { + + const port = 6002; + + const server = http.createServer((request, response) => { + response.write(JSON.stringify({ + timestamp: request.headers.timestamp, + date: request.headers.date + })); + response.end(); + }); + + const client = new Client('API_KEY', 'SECRET', { + ssl: false, + host: 'localhost', + port: port + }); + + server.listen(port); + + + // When not specified, assume `timestamp` + const one: any = await client.request({ method: 'GET', path: '/' }); + expect(one.timestamp).toBeDefined(); + expect(one.date).toBeUndefined(); + + // Respect the `date` preference when set to true + client._settings.useDateHeader = true; + const two: any = await client.request({ method: 'GET', path: '/' }); + expect(two.timestamp).toBeUndefined(); + expect(two.date).toBeDefined(); + + // Respect the `date` preference when set to false + client._settings.useDateHeader = false; + const three: any = await client.request({ method: 'GET', path: '/' }); + expect(three.timestamp).toBeDefined(); + expect(three.date).toBeUndefined(); + + server.close(); + }); + test('makes requests using headers declared in class instantiation as well as request calls', async () => { expect.assertions(9); diff --git a/test/Server.test.js b/test/Server.test.js index 9c59bb1..fcc6798 100644 --- a/test/Server.test.js +++ b/test/Server.test.js @@ -321,7 +321,7 @@ describe('Server class', () => { } }); - test('rejects authentication when date header is missing', async () => { + test('rejects authentication when both the date and timestamp headers are missing', async () => { expect.assertions(1);