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