--- /dev/null
+message="bump package version to %s"
## [Unreleased]
+## [v1.5.0] - TBD
+
+- Migrated resource authenticator into this package, for inter-server API authentication.
+
## [v1.4.1] - 2024-05-11
- updated dependencies and devDependencies
- `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.
'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,
'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.
mysteryBoxLogger,
obscureAuthorizationHeader,
omit,
+ randomBytesAsync,
});
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
--- /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
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');
await this._otpDisable(dbCtx, ctx, authData);
return;
- case 'confirm':
+ case 'confirm':
await this._otpConfirm(dbCtx, ctx);
return;
"@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",
{
"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",
"@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",
/* 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');
/* 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');
--- /dev/null
+'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
'use strict';
-const assert = require('assert');
+const assert = require('node:assert');
const sinon = require('sinon');
const SessionManager = require('../../lib/session-manager');
/* eslint-env mocha */
'use strict';
-const assert = require('assert');
+const assert = require('node:assert');
const { IAHTML } = require('../../../lib/template');
const lintHtml = require('../../lint-html');
/* eslint-env mocha */
'use strict';
-const assert = require('assert');
+const assert = require('node:assert');
const { LoginHTML } = require('../../../lib/template');
const lintHtml = require('../../lint-html');
/* eslint-env mocha */
'use strict';
-const assert = require('assert');
+const assert = require('node:assert');
const { OTPHTML } = require('../../../lib/template');
const lintHtml = require('../../lint-html');
/* eslint-env mocha */
'use strict';
-const assert = require('assert');
+const assert = require('node:assert');
const { SettingsHTML } = require('../../../lib/template');
const lintHtml = require('../../lint-html');
'authenticationSuccess',
'authenticationUpdateCredential',
'authenticationUpdateOTPKey',
+ 'resourceGet',
];
const stubDatabase = {