initial commit
[squeep-resource-authentication-module] / lib / resource-authenticator.js
diff --git a/lib/resource-authenticator.js b/lib/resource-authenticator.js
new file mode 100644 (file)
index 0000000..04ac999
--- /dev/null
@@ -0,0 +1,201 @@
+'use strict';
+
+/**
+ * 
+ */
+
+const crypto = require('crypto');
+const uuid = require('uuid');
+const common = require('./common');
+const Enum = require('./enum');
+const { ResponseError, ResourceAuthenticatorError } = require('./errors');
+
+const _fileScope = common.fileScope(__filename);
+
+/**
+ * @typedef {Object} ResourceRecord
+ * @property {String} secret
+ */
+
+const defaultOptions = {
+  digestAlgorithm: 'sha256',
+  graceSeconds: 60,
+  saltBytes: 21,
+};
+
+class ResourceAuthenticator {
+  /**
+   * 
+   * @param {Console} logger
+   * @param {*} db
+   * @param {(dbCtx: any, identifier: String) => Promise<ResourceRecord>} db.resourceGet
+   * @param {((dbCtx: any) => Promise<any>) => Promise<void>} db.context
+   * @param {Object=} options
+   * @param {Object=} options.resourceAuthenticator
+   * @param {String=} options.resourceAuthenticator.digestAlgorithm
+   * @param {Number=} options.resourceAuthenticator.graceSeconds
+   * @param {Number=} options.resourceAuthenticator.saltBytes
+   */
+  constructor(logger, db, options = { resourceAuthenticator: {} }) {
+    this.logger = logger;
+    this.db = db;
+    Object.assign(this, defaultOptions, options.resourceAuthenticator);
+  }
+
+
+  /**
+   * Get the current epoch.
+   */
+  static get currentEpoch() {
+    return Math.ceil(Date.now() / 1000);
+  }
+
+
+  /**
+   * Spice up the payload.
+   * @returns {String}
+   */
+  async getSalt() {
+    const saltBytes = await common.randomBytesAsync(this.saltBytes);
+    return common.base64ToBase64URL(saltBytes.toString('base64'));
+  }
+
+
+  /**
+   * Require a request includes a valid resource bearer token.
+   * Resource bearer tokens are of the form 'identifier:epoch:salt:${hmac(identifier + epoch + salt)}'
+   * with the hmac using a shared secret.
+   * Identifier is an alternate representation of the UUID referencing the resource.
+   * 
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async required(req, res, ctx) {
+    const _scope = _fileScope('required');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    try {
+      const authHeader = req.getHeader(Enum.Header.Authorization);
+      if (!authHeader) {
+        throw new ResourceAuthenticatorError('auth header missing')
+      }
+
+      const [ authMethod, credentials ] = common.splitFirst(authHeader, ' ', '');
+      if (authMethod.toLowerCase() !== 'bearer' || !credentials) {
+        throw new ResourceAuthenticatorError('auth header not bearer');
+      }
+
+      const tokenParts = credentials.split(':');
+      const [ tIdentifier, tEpoch, tSalt, tHMAC ] = tokenParts;
+      if (tokenParts.length !== 4 || !tIdentifier || !tEpoch || !tSalt || !tHMAC) {
+        throw new ResourceAuthenticatorError('unparsable bearer token');
+      }
+
+      const identifier = ResourceAuthenticator.embiggenIdentifier(tIdentifier);
+
+      let resource;
+      await this.db.context(async (dbCtx) => {
+        resource = await this.db.resourceGet(dbCtx, identifier);
+      });
+      if (!resource) {
+        throw new ResourceAuthenticatorError('invalid resource');
+      }
+
+      const resourceEpoch = parseInt(tEpoch, 10);
+      const currentEpoch = ResourceAuthenticator.currentEpoch;
+      const epochDrift = Math.abs(currentEpoch - resourceEpoch);
+      if (epochDrift > this.graceSeconds) {
+        throw new ResourceAuthenticatorError('timestamp out of grace period');
+      }
+
+      const hmac = this.createDigest(resource.secret, tIdentifier, tEpoch, tSalt);
+      if (hmac !== tHMAC) {
+        throw new ResourceAuthenticatorError('mismatched HMAC');
+      }
+
+      ctx.resourceIdentifier = identifier;
+      this.logger.debug(_scope, 'success', { ctx });
+
+    } catch (e) {
+      if (e instanceof ResourceAuthenticatorError) {
+        this.logger.debug(_scope, 'invalid', { error: e, ctx });
+        return ResourceAuthenticator.requestBearer(res);
+      }
+      this.logger.error(_scope, 'failed', { error: e, ctx });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Return the authentication header for an identifier.
+   * @param {String} identifier UUID
+   */
+  async authenticate(identifier, secret) {
+    const currentEpoch = ResourceAuthenticator.currentEpoch.toString();
+    const smallIdentifier = ResourceAuthenticator.ensmallenIdentifier(identifier);
+    const salt = await this.getSalt();
+    const parts = [
+      smallIdentifier,
+      currentEpoch,
+      salt,
+      this.createDigest(secret, smallIdentifier, currentEpoch, salt),
+    ];
+    return parts.join(':');
+  }
+
+
+  /**
+   * Compact uuid identifier, as 24 url-safe characters.
+   * @param {String} identifier UUID
+   * @returns {String} b64u-encoded UUID
+   */
+  static ensmallenIdentifier(identifier) {
+    const uuidBase64 = Buffer.from(uuid.parse(identifier)).toString('base64');
+    return common.base64ToBase64URL(uuidBase64);
+  }
+
+
+  /**
+   * Expand compacted uuid identifier.
+   * @param {String} identifier b64u-encoded UUID
+   * @returns {String} UUID
+   */
+  static embiggenIdentifier(small) {
+    const uuidBase64 = common.base64URLToBase64(small);
+    const uuidBuffer = Buffer.from(uuidBase64, 'base64');
+    return uuid.stringify(uuidBuffer);
+  }
+
+
+  /**
+   * Smoosh some contents through the chosen HMAC.
+   * @param {Buffer} secret
+   * @param  {...any} contents
+   * @returns {String}
+   */
+  createDigest(secret, ...contents) {
+    const hmac = crypto.createHmac(this.digestAlgorithm, secret);
+    contents.forEach((content) => hmac.update(Buffer.from(content)));
+    const digestBase64 = hmac.digest('base64');
+    return common.base64ToBase64URL(digestBase64);
+  }
+
+
+  /**
+   * Abort and request bearer authentication.
+   * @param {http.ServerResponse} res
+   * @param {Object} response defaults to Unauthorized
+   */
+  static requestBearer(res, response = Enum.ErrorResponse.Unauthorized) {
+    res.setHeader(Enum.Header.WWWAuthenticate, 'Bearer');
+    throw new ResponseError(response);
+  }
+
+}
+
+module.exports = {
+  ResourceAuthenticator,
+  ResourceAuthenticatorError,
+};