From: Justin Wind Date: Sat, 11 May 2024 22:21:58 +0000 (-0700) Subject: migrate ResourceAuthenticator into here from separate package X-Git-Url: https://git.squeep.com/?p=squeep-authentication-module;a=commitdiff_plain;h=HEAD migrate ResourceAuthenticator into here from separate package --- diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..258cd85 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +message="bump package version to %s" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1f989..e4c68e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Releases and notable changes to this project are documented here. ## [Unreleased] +## [v1.5.0] - TBD + +- Migrated resource authenticator into this package, for inter-server authentication. + ## [v1.4.1] - 2024-05-11 - updated dependencies and devDependencies diff --git a/README.md b/README.md index 6b4400e..c4234f6 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,21 @@ Class providing service handler functions for rendering and processing session l - `sessionNavLinks` call from app templates to populate navLinks for account settings and logout +### ResourceAuthenticator + +Class which fetches and validates resource identifiers and their secrets from database. + +Resources are assumed to be other services making API calls. + +This is just a time-gated pre-shared-secret HMAC Bearer token scheme. + +Default token format is a ':'-separated concatenation of: + +- resource identifier, which is a UUID, encoded as 24 bytes in base64url +- current epoch, encoded as base10 string +- salt value, randomness encoded as 28 bytes in base64url +- sha256 HMAC digest of previous data, encoded in base64url + ### Other Notes For the moment, this imposes a web structure of /admin/* for authentication management paths. diff --git a/index.js b/index.js index 5d9da0c..b481864 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,14 @@ 'use strict'; const Authenticator = require('./lib/authenticator'); +const ResourceAuthenticator = require('./lib/resource-authenticator'); const SessionManager = require('./lib/session-manager'); const stdioCredential = require('./lib/stdio-credential'); const templateHelpers = require('./lib/template/helpers'); module.exports = { Authenticator, + ResourceAuthenticator, SessionManager, stdioCredential, ...templateHelpers, diff --git a/lib/common.js b/lib/common.js index 120c870..5563254 100644 --- a/lib/common.js +++ b/lib/common.js @@ -1,7 +1,10 @@ 'use strict'; const { common } = require('@squeep/api-dingus'); +const { randomBytes } = require('node:crypto'); +const { promisify } = require('node:util'); +const randomBytesAsync = promisify(randomBytes); /** * Recursively freeze an object. @@ -66,4 +69,5 @@ module.exports = Object.assign(Object.create(common), { mysteryBoxLogger, obscureAuthorizationHeader, omit, + randomBytesAsync, }); diff --git a/lib/errors.js b/lib/errors.js index 98a6f18..5f646ab 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -2,6 +2,17 @@ const { Errors } = require('@squeep/api-dingus'); +class ResourceAuthenticatorError extends Error { + constructor(...args) { + super(...args); + delete this.stack; + } + get name() { + return this.constructor.name; + } +} + module.exports = { + ResourceAuthenticatorError, ...Errors, }; \ No newline at end of file diff --git a/lib/resource-authenticator.js b/lib/resource-authenticator.js new file mode 100644 index 0000000..29d0111 --- /dev/null +++ b/lib/resource-authenticator.js @@ -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} result + */ + +/** + * @typedef {object} ResourceDBInterface + * @property {(DBContextExec) => Promise} context db context runner + * @property {(dbCtx: any, identifier: string) => Promise} 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} 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 {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} + */ + 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 diff --git a/lib/session-manager.js b/lib/session-manager.js index 7b33d86..d399e5b 100644 --- a/lib/session-manager.js +++ b/lib/session-manager.js @@ -8,7 +8,7 @@ const { Communication: IndieAuthCommunication } = require('@squeep/indieauth-helper'); const { MysteryBox } = require('@squeep/mystery-box'); const { TOTP } = require('@squeep/totp'); -const { randomUUID } = require('crypto'); +const { randomUUID } = require('node:crypto'); const common = require('./common'); const Enum = require('./enum'); const Template = require('./template'); @@ -604,7 +604,7 @@ class SessionManager { await this._otpDisable(dbCtx, ctx, authData); return; - case 'confirm': + case 'confirm': await this._otpConfirm(dbCtx, ctx); return; diff --git a/package-lock.json b/package-lock.json index cadd7ce..ea9befc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.6.1", "@squeep/indieauth-helper": "^1", "@squeep/mystery-box": "^2", - "@squeep/totp": "^1" + "@squeep/totp": "^1", + "uuid": "^9" }, "devDependencies": { "@squeep/eslint-config": "^1", diff --git a/package.json b/package.json index 9bcb498..60cfb59 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@squeep/authentication-module", "version": "1.4.1", - "description": "Wrangles authenticated sessions; intended for use in Squeep Framework Applications.", + "description": "Wrangles authenticated sessions, endpoints, and resources; intended for use in Squeep Framework Applications.", "main": "index.js", "scripts": { "audit": "npm audit", @@ -39,7 +39,8 @@ "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.6.1", "@squeep/indieauth-helper": "^1", "@squeep/mystery-box": "^2", - "@squeep/totp": "^1" + "@squeep/totp": "^1", + "uuid": "^9" }, "optionalDependencies": { "argon2": "^0.40.1", diff --git a/test/lib/authenticator.js b/test/lib/authenticator.js index 84ee1c5..75ae9a8 100644 --- a/test/lib/authenticator.js +++ b/test/lib/authenticator.js @@ -3,7 +3,7 @@ /* eslint-disable jsdoc/require-jsdoc */ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const sinon = require('sinon'); const Authenticator = require('../../lib/authenticator'); const stubLogger = require('../stub-logger'); diff --git a/test/lib/common.js b/test/lib/common.js index 82b80f0..5ddc3d7 100644 --- a/test/lib/common.js +++ b/test/lib/common.js @@ -1,7 +1,7 @@ /* eslint-env mocha */ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const sinon = require('sinon'); const stubLogger = require('../stub-logger'); const common = require('../../lib/common'); diff --git a/test/lib/resource-authenticator.js b/test/lib/resource-authenticator.js new file mode 100644 index 0000000..7f360e3 --- /dev/null +++ b/test/lib/resource-authenticator.js @@ -0,0 +1,239 @@ +'use strict'; + +const assert = require('node:assert'); +const sinon = require('sinon'); +const ResourceAuthenticator = require('../../lib/resource-authenticator'); +const stubLogger = require('../stub-logger'); +const stubDb = require('../stub-db'); +const Config = require('../stub-config'); +const { ResponseError, ResourceAuthenticatorError } = require('../../lib/errors'); +const Enum = require('../../lib/enum'); + +describe('Resource Authenticator', function () { + const noExpectedException = 'did not receive expected exception'; + let ra, options; + + beforeEach(function () { + stubDb._reset(); + stubLogger._reset(); + options = new Config('test'); + ra = new ResourceAuthenticator(stubLogger, stubDb, options); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('covers no option default', function () { + ra = new ResourceAuthenticator(stubLogger, stubDb); + }); + + describe('currentEpoch', function () { + it('covers', function () { + const now = 1648836413503; + const expected = Math.ceil(now / 1000); + sinon.stub(Date, 'now').returns(now); + const result = ResourceAuthenticator.currentEpoch; + assert.strictEqual(result, expected); + }); + }); // currentEpoch + + describe('Identifier Compaction', function () { + it('reciprocates', function () { + const identifier = '6eaed948-b1e4-11ec-9a91-0025905f714a'; + const smaller = ResourceAuthenticator.ensmallenIdentifier(identifier); + const bigger = ResourceAuthenticator.embiggenIdentifier(smaller); + assert.strictEqual(bigger, identifier); + }); + }); // Identifier Compaction + + describe('getSalt', function () { + it('covers', async function () { + const result = await ra.getSalt(); + assert(result); + assert.strictEqual(result.length, 28); + }); + }); // getSalt + + describe('createDigest', function () { + let secret; + beforeEach(function () { + secret = 'secret'; + }); + it('creates empty digest', function () { + const result = ra.createDigest(secret); + const expected = '-eZuF5tnR65UEI-C-K3os8Jddv0wr95sOVgixTAZYWk'; + assert.strictEqual(result, expected); + }); + it('creates digest', function () { + const result = ra.createDigest(secret, 'data'); + const expected = 'GywWt1vSqHDBFBU8zaW8_KYzFLxyL6Fg1pDeEzzLuds'; + assert.strictEqual(result, expected); + }); + }); // createDigest + + describe('authenticate', function () { + it('covers', async function () { + const identifier = '6eaed948-b1e4-11ec-9a91-0025905f714a'; + const secret = 'secrety'; + sinon.stub(ResourceAuthenticator, 'currentEpoch').get(() => 1648836029); + sinon.stub(ra, 'getSalt').resolves('xxxxx'); + const expected = 'Bearer bq7ZSLHkEeyakQAlkF9xSg:1648836029:xxxxx:fdUYC8Gqe0nAyX_-SWvRsPsx0UjY-vV-Ff0A52j6Zfw'; + const result = await ra.authenticate(identifier, secret); + assert.strictEqual(result, expected); + }); + }); // authenticate + + describe('required', function () { + let resource, res, req, ctx; + const validBearerHeader = 'Bearer bq7ZSLHkEeyakQAlkF9xSg:1648836029:xxxxx:fdUYC8Gqe0nAyX_-SWvRsPsx0UjY-vV-Ff0A52j6Zfw'; + beforeEach(function () { + resource = { + secret: 'secrety', + }; + req = { + getHeader: sinon.stub(), + }; + res = { + setHeader: sinon.stub(), + }; + ctx = { + params: {}, + parsedBody: {}, + queryParams: {}, + session: {}, + }; + }); + it('requires auth header', async function () { + try { + await ra.required(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError, noExpectedException); + assert.strictEqual(e.statusCode, 401); + } + }); + it('requires bearer token', async function () { + req.getHeader.returns('Basic Zm9vcABiYXJr'); + try { + await ra.required(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError, noExpectedException); + assert.strictEqual(e.statusCode, 401); + } + }); + it('requires proper bearer token', async function () { + req.getHeader.returns('Bearer Zm9vcABiYXJr'); + try { + await ra.required(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError, noExpectedException); + assert.strictEqual(e.statusCode, 401); + } + }); + it('requires identifier to exist', async function () { + req.getHeader.returns(validBearerHeader); + try { + await ra.required(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError, noExpectedException); + assert.strictEqual(e.statusCode, 401); + } + }); + it('covers db failure', async function () { + const expected = new Error('oh no'); + ra.db.resourceGet.rejects(expected); + req.getHeader.returns(validBearerHeader); + try { + await ra.required(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expected, noExpectedException); + } + }); + it('requires timestamp within grace', async function () { + sinon.stub(ResourceAuthenticator, 'currentEpoch').get(() => 1648838184); + ra.db.resourceGet.resolves(resource); + req.getHeader.returns(validBearerHeader); + try { + await ra.required(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError, noExpectedException); + assert.strictEqual(e.statusCode, 401); + } + }); + it('requires digest to match', async function () { + sinon.stub(ResourceAuthenticator, 'currentEpoch').get(() => 1648836031); + ra.db.resourceGet.resolves(resource); + req.getHeader.returns('Bearer bq7ZSLHkEeyakQAlkF9xSg:1648836029:xxxxx:invalid1M2j9wtoerc3Pqe6kRzqFrkrkwqdeYXG331Q'); + try { + await ra.required(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError, noExpectedException); + assert.strictEqual(e.statusCode, 401); + } + }); + it('succeeds', async function () { + sinon.stub(ResourceAuthenticator, 'currentEpoch').get(() => 1648836031); + ra.db.resourceGet.resolves(resource); + req.getHeader.returns(validBearerHeader); + await ra.required(req, res, ctx); + assert.strictEqual(ra.logger.debug.args[1][1], 'success'); + }); + it('covers extra bearer token fields', async function () { + sinon.stub(ResourceAuthenticator, 'currentEpoch').get(() => 1648836031); + ra.db.resourceGet.resolves(resource); + req.getHeader.returns(validBearerHeader + ':extra'); + await ra.required(req, res, ctx); + assert.deepStrictEqual(ra.logger.debug.args[1][2], { tRest: ['extra'] }); + assert.strictEqual(ra.logger.debug.args[2][1], 'success'); + }); + }); // required + + describe('requestBearer', function () { + let res; + beforeEach(function () { + res = { + setHeader: sinon.stub(), + }; + }); + it('covers default response', function () { + try { + ResourceAuthenticator.requestBearer(res); + assert.fail(noExpectedException); + } catch (e) { + assert(res.setHeader.called); + assert.strictEqual(res.setHeader.args[0][0], 'WWW-Authenticate'); + assert.strictEqual(res.setHeader.args[0][1], 'Bearer'); + assert(e instanceof ResponseError, noExpectedException); + assert.strictEqual(e.statusCode, 401); + } + }); + it('covers other response', function () { + try { + ResourceAuthenticator.requestBearer(res, Enum.ErrorResponse.Forbidden); + assert.fail(noExpectedException); + } catch (e) { + assert(res.setHeader.called); + assert.strictEqual(res.setHeader.args[0][0], 'WWW-Authenticate'); + assert.strictEqual(res.setHeader.args[0][1], 'Bearer'); + assert(e instanceof ResponseError); + assert.strictEqual(e.statusCode, 403); + } + }); + }); // requestBearer + + describe('ResourceAuthenticatorError', function () { + it('covers', function () { + const e = new ResourceAuthenticatorError(); + const result = e.name; + assert.strictEqual(result, 'ResourceAuthenticatorError'); + }); + }); // ResourceAuthenticationError + +}); // Resource Authenticator \ No newline at end of file diff --git a/test/lib/session-manager.js b/test/lib/session-manager.js index 888cfa5..16243d7 100644 --- a/test/lib/session-manager.js +++ b/test/lib/session-manager.js @@ -3,7 +3,7 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const sinon = require('sinon'); const SessionManager = require('../../lib/session-manager'); diff --git a/test/lib/template/ia-html.js b/test/lib/template/ia-html.js index 87bdf0a..aabb9ac 100644 --- a/test/lib/template/ia-html.js +++ b/test/lib/template/ia-html.js @@ -1,7 +1,7 @@ /* eslint-env mocha */ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const { IAHTML } = require('../../../lib/template'); const lintHtml = require('../../lint-html'); diff --git a/test/lib/template/login-html.js b/test/lib/template/login-html.js index 04e7555..44d8fbb 100644 --- a/test/lib/template/login-html.js +++ b/test/lib/template/login-html.js @@ -1,7 +1,7 @@ /* eslint-env mocha */ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const { LoginHTML } = require('../../../lib/template'); const lintHtml = require('../../lint-html'); diff --git a/test/lib/template/otp-html.js b/test/lib/template/otp-html.js index 0e84a49..2a49a71 100644 --- a/test/lib/template/otp-html.js +++ b/test/lib/template/otp-html.js @@ -1,7 +1,7 @@ /* eslint-env mocha */ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const { OTPHTML } = require('../../../lib/template'); const lintHtml = require('../../lint-html'); diff --git a/test/lib/template/settings-html.js b/test/lib/template/settings-html.js index 3bd5619..e4ba529 100644 --- a/test/lib/template/settings-html.js +++ b/test/lib/template/settings-html.js @@ -1,7 +1,7 @@ /* eslint-env mocha */ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const { SettingsHTML } = require('../../../lib/template'); const lintHtml = require('../../lint-html'); diff --git a/test/stub-db.js b/test/stub-db.js index f5306b3..537c92b 100644 --- a/test/stub-db.js +++ b/test/stub-db.js @@ -14,6 +14,7 @@ const stubFns = [ 'authenticationSuccess', 'authenticationUpdateCredential', 'authenticationUpdateOTPKey', + 'resourceGet', ]; const stubDatabase = {