--- /dev/null
+'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,
+};