migrate ResourceAuthenticator into here from separate package
[squeep-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..a635d9c
--- /dev/null
@@ -0,0 +1,223 @@
+'use strict';
+
+const { createHmac } = require('node:crypto');
+const uuid = require('uuid');
+const common = require('./common');
+const Enum = require('./enum');
+const { ResourceAuthenticatorError, ResponseError } = require('./errors');
+
+const _fileScope = common.fileScope(__filename);
+
+/**
+ * @typedef {import('node:http')} http
+ */
+
+/**
+ * @typedef {object} ResourceRecord
+ * @property {string} secret secret
+ */
+
+/**
+ * @typedef {object} ConsoleLike
+ * @property {Function} debug log debug
+ * @property {Function} error log error
+ * @property {Function} info log info
+ */
+
+/**
+ * @callback DBContextExec
+ * @param {object} dbCtx db context
+ * @returns {Promise<any>} result
+ */
+
+/**
+ * @typedef {object} ResourceDBInterface
+ * @property {(DBContextExec) => Promise<any>} context db context runner
+ * @property {(dbCtx: any, identifier: string) => Promise<ResourceRecord>} resourceGet get a resource entry
+ */
+
+/**
+ * @typedef {object} ResourceAuthenticatorOptions
+ * @property {string=} digestAlgorithm digest algorithm
+ * @property {number=} graceSeconds grace seconds, give or take
+ * @property {number=} saltBytes salt bytes
+ */
+
+/**
+ * @type {ResourceAuthenticatorOptions}
+ */
+const defaultResourceAuthenticatorOptions = {
+  digestAlgorithm: 'sha256',
+  graceSeconds: 60,
+  saltBytes: 21,
+};
+
+class ResourceAuthenticator {
+  /**
+   * 
+   * @param {ConsoleLike} logger logger instance
+   * @param {ResourceDBInterface} db db instance
+   * @param {object} options options
+   * @param {ResourceAuthenticatorOptions} options.resourceAuthenticator resource authenticator options
+   */
+  constructor(logger, db, options = {}) {
+    this.logger = logger;
+    this.db = db;
+    common.setOptions(this, defaultResourceAuthenticatorOptions, options?.resourceAuthenticator || {});
+  }
+
+
+  /**
+   * Get the current epoch.
+   * @returns {number} epoch seconds
+   */
+  static get currentEpoch() {
+    return Math.ceil(Date.now() / 1000);
+  }
+
+
+  /**
+   * Compact uuid identifier as 24 character url-safe string.
+   * @param {string} identifier uuid
+   * @returns {string} base64url encoded uuid
+   */
+  static ensmallenIdentifier(identifier) {
+    return Buffer.from(uuid.parse(identifier)).toString('base64url');
+  }
+
+
+  /**
+   * Expand compacted uuid identifier.
+   * @param {string} compact base64url encoded uuid
+   * @returns {string} uuid
+   */
+  static embiggenIdentifier(compact) {
+    const uuidBuffer = Buffer.from(compact, 'base64url');
+    return uuid.stringify(uuidBuffer);
+  }
+
+
+  /**
+   * Get an encoded salt string.
+   * @param {number} saltBytes salt bytes
+   * @returns {Promise<string>} base64url encoded
+   */
+  async getSalt(saltBytes = this.saltBytes) {
+    const saltBuffer = await common.randomBytesAsync(saltBytes);
+    return saltBuffer.toString('base64url');
+  }
+
+
+  /**
+   * Smoosh some contents through the configured HMAC.
+   * @param {Buffer|string} secret secret
+   * @param  {...any} contents contents
+   * @returns {string} base64url encoded digest
+   */
+  createDigest(secret, ...contents) {
+    const hmac = createHmac(this.digestAlgorithm, secret);
+    contents.forEach((content) => hmac.update(Buffer.from(content)));
+    return hmac.digest('base64url');
+  }
+
+  /**
+   * Return an authorization header value for an identifier and secret.
+   * @param {string} identifier uuid
+   * @param {string} secret secret
+   * @returns {Promise<string>} authorization header
+   */
+  async authenticate(identifier, secret) {
+    const authenticationType = 'Bearer';
+    const currentEpoch = this.constructor.currentEpoch.toString();
+    const smallIdentifier = this.constructor.ensmallenIdentifier(identifier);
+    const salt = await this.getSalt();
+    const token = [
+      smallIdentifier,
+      currentEpoch,
+      salt,
+      this.createDigest(secret, smallIdentifier, currentEpoch, salt),
+    ].join(':');
+    return [authenticationType, token].join(' ');
+  }
+
+
+  /**
+   * Require a request to include a valid resource bearer token.
+   * Resource bearer tokens are of the form 'compact-identifier:epoch:salt:${hmac(identifier + epoch + salt)}'
+   * where the hmac using a pre-shared secret.
+   * Sets ctx.resourceIdentifier on success.
+   * @param {http.IncomingMessage} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @returns {Promise<void>}
+   */
+  async required(req, res, ctx) {
+    const _scope = _fileScope('required');
+    this.logger.debug(_scope, 'called');
+
+    try {
+      const authHeader = req.getHeader(Enum.Header.Authorization);
+      if (!authHeader) {
+        throw new ResourceAuthenticatorError('no authz header');
+      }
+
+      const [ authMethod, credentials ] = common.splitFirst(authHeader, ' ', '');
+      if (authMethod.toLowerCase() !== 'bearer' || !credentials) {
+        throw new ResourceAuthenticatorError('authz header not bearer type');
+      }
+
+      const [tIdentifier, tEpoch, tSalt, tDigest, ...tRest] = credentials.split(':');
+      if (!tIdentifier || !tEpoch || !tSalt || !tDigest) {
+        throw new ResourceAuthenticatorError('unparsable bearer token');
+      }
+      if (tRest.length) {
+        this.logger.debug(_scope, 'unexpected additional fields in bearer token', { tRest });
+      }
+
+      const identifier = this.constructor.embiggenIdentifier(tIdentifier);
+
+      const resource = await this.db.context(async (dbCtx) => {
+        return await this.db.resourceGet(dbCtx, identifier);
+      }); // dbCtx
+      if (!resource) {
+        throw new ResourceAuthenticatorError('invalid resource');
+      }
+
+      const resourceEpoch = parseInt(tEpoch, 10);
+      const epochDrift = Math.abs(resourceEpoch - this.constructor.currentEpoch);
+      if (epochDrift > this.graceSeconds) {
+        throw new ResourceAuthenticatorError('timestamp out of grace period');
+      }
+
+      const digest = this.createDigest(resource.secret, tIdentifier, tEpoch, tSalt);
+      if (digest !== tDigest) {
+        throw new ResourceAuthenticatorError('invalid digest');
+      }
+
+      // Success
+      ctx.resourceIdentifier = identifier;
+      this.logger.debug(_scope, 'success');
+    } catch (e) {
+      if (e instanceof ResourceAuthenticatorError) {
+        this.logger.debug(_scope, 'invalid', { error: e });
+        return this.constructor.requestBearer(res);
+      }
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Return a status code requesting bearer authentication.
+   * @param {http.ServerResponse} res response
+   * @param {object} response defaults to Unauthorized status
+   */
+  static requestBearer(res, response = Enum.ErrorResponse.Unauthorized) {
+    res.setHeader(Enum.Header.WWWAuthenticate, 'Bearer');
+    throw new ResponseError(response);
+  }
+
+}
+
+module.exports = ResourceAuthenticator;
\ No newline at end of file