Initial Commit
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 31s
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 31s
This commit is contained in:
commit
6895111f69
13 changed files with 4107 additions and 0 deletions
109
.forgejo/workflows/publish.yml
Normal file
109
.forgejo/workflows/publish.yml
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
name: Publish to Private NPM Registry
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: default
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Checkout the repository
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Set up NPM Auth Token
|
||||||
|
- name: Set up NPM Auth Token
|
||||||
|
run: echo "NODE_AUTH_TOKEN=${{ secrets.NPM_TOKEN }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# Set up Node.js
|
||||||
|
- name: Set up Node.js version
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
|
||||||
|
#
|
||||||
|
# > Version Spec of the version to use in SemVer notation.
|
||||||
|
# > It also admits such aliases as lts/*, latest, nightly and canary builds
|
||||||
|
# > Examples: 12.x, 10.15.1, >=10.15.0, lts/Hydrogen, 16-nightly, latest, node
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
|
||||||
|
#
|
||||||
|
# > Optional registry to set up for auth. Will set the registry in a project level .npmrc and .yarnrc file,
|
||||||
|
# > and set up auth to read in from env.NODE_AUTH_TOKEN.
|
||||||
|
# > Default: ''
|
||||||
|
registry-url: 'https://npm.pkg.bridgemanaccessible.ca'
|
||||||
|
|
||||||
|
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
|
||||||
|
#
|
||||||
|
# > Optional scope for authenticating against scoped registries.
|
||||||
|
# > Will fall back to the repository owner when using the GitHub Packages registry (https://npm.pkg.github.com/).
|
||||||
|
scope: '@BridgemanAccessible'
|
||||||
|
|
||||||
|
# Transpile/Build the package (TypeScript -> JavaScript)
|
||||||
|
- name: Transpile/Build the package (TypeScript -> JavaScript)
|
||||||
|
run: |
|
||||||
|
# Because Yarn is used locally better to install and use it than have to debug weird inconsistencies
|
||||||
|
npm install --global yarn
|
||||||
|
|
||||||
|
# Install needed dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Run tests to ensure the package is working as expected before publishing
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Build the package
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
- name: Determine Version and Increment (if needed)
|
||||||
|
id: version_check
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
|
NAME=$(node -p "require('./package.json').name")
|
||||||
|
LATEST_VERSION=$(npm show $NAME version --registry https://npm.pkg.bridgemanaccessible.ca 2>/dev/null || echo "0.0.0")
|
||||||
|
echo "Latest version: $LATEST_VERSION"
|
||||||
|
|
||||||
|
if [ "$LATEST_VERSION" != "$VERSION" ]; then
|
||||||
|
echo "Manually updated version detected: $VERSION"
|
||||||
|
else
|
||||||
|
NEW_VERSION=$(npm version patch --no-git-tag-version)
|
||||||
|
echo "New version: $NEW_VERSION"
|
||||||
|
echo "new_version=$NEW_VERSION" >> $GITHUB_ENV
|
||||||
|
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit Version Change (if needed)
|
||||||
|
if: steps.version_check.outputs.version_changed == 'true'
|
||||||
|
run: |
|
||||||
|
# Update remote URL to use the GITHUB_TOKEN for authentication
|
||||||
|
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.bridgemanaccessible.ca/${{ github.repository }}.git
|
||||||
|
|
||||||
|
# Setup git user details for committing the version change
|
||||||
|
git config user.name "Forgejo Actions"
|
||||||
|
git config user.email "actions@git.bridgemanaccessible.ca"
|
||||||
|
|
||||||
|
# Commit the version change to the `package.json` file
|
||||||
|
git add package.json
|
||||||
|
git commit -m "[Forgejo Actions] Update version to ${{ env.new_version }}"
|
||||||
|
|
||||||
|
# Push the changes to the repository
|
||||||
|
git push origin HEAD:main
|
||||||
|
|
||||||
|
# Publish to private NPM registry
|
||||||
|
- name: Publish the package
|
||||||
|
run: |
|
||||||
|
# Copy over the files to the build output (`dist`) folder
|
||||||
|
cp package.json dist/package.json
|
||||||
|
cp README.md dist/README.md
|
||||||
|
cp LICENSE dist/LICENSE
|
||||||
|
|
||||||
|
# Change directory to the build output (`dist`) folder
|
||||||
|
cd dist
|
||||||
|
|
||||||
|
# Publish the package to the private NPM registry
|
||||||
|
npm publish --registry http://npm.pkg.bridgemanaccessible.ca/
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Node modules
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Yarn stuff
|
||||||
|
.yarn/
|
||||||
|
.yarnrc.yml
|
||||||
|
|
||||||
|
# Credentials/Secrets
|
||||||
|
.npmrc
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Automation Scripts
|
||||||
|
unpublish-publish.ps1
|
||||||
|
unpublish-publish.sh
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Bridgeman Accessible
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
72
README.md
Normal file
72
README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Bridgeman Accessible Auth Keystore
|
||||||
|
This is the implementation of the Bridgeman Accessible Auth Keystore.
|
||||||
|
|
||||||
|
Keystores form the foundation for how [JSON Web Keys (JWKs)](#jwks-and-node-jose) management happens within the [Bridgeman Accessible Auth library](https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth). JWKs play an important role in signing and encrypting JWTs and other information which are used throughout that library.
|
||||||
|
|
||||||
|
Having a flexible but reliable way to store and retrieve JWKs is one of the most critical backbones for that infrastructure to work properly.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Provided Here](#provided-here)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Background / Info](#background--info)
|
||||||
|
- [Why A Separate Library](#why-a-separate-library)
|
||||||
|
- [Flexibility / Extensibility](#flexibility--extensibility)
|
||||||
|
- [Light Weight](#light-weight)
|
||||||
|
- [JWKs and `node-jose`](#jwks-and-node-jose)
|
||||||
|
- [Existing Keystore Types](#existing-keystore-types)
|
||||||
|
- [New Keystore Types](#new-keystore-types)
|
||||||
|
- [Creating A New Keystore Type](#creating-a-new-keystore-type)
|
||||||
|
- [Registering A New Keystore Type](#registering-a-new-keystore-type)
|
||||||
|
- [Using The New Keystore Type](#using-the-new-keystore-type)
|
||||||
|
|
||||||
|
## Provided Here
|
||||||
|
This package/library provides the "abstract" underlying code to make Keystores work. In particular it implements the [Registry Pattern](https://www.geeksforgeeks.org/system-design/registry-pattern/) and provides the centralized registry (`Keystore`) and the underlying abstract base type (`BaseKeystore`). See the [New Keystore Type](#new-keystore-types) section below for more details how this all fits together.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
This package includes testing to be able to validate that the core logic works.
|
||||||
|
|
||||||
|
The test suite can be run by running `yarn test` in any terminal/shell that supports Yarn. The test suite is run automatically as part of the publish CI/CD automation on push.
|
||||||
|
|
||||||
|
## Background / Info
|
||||||
|
|
||||||
|
### Why A Separate Library
|
||||||
|
If it forms one of the main backbones for the [Auth library](https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth) it makes sense to ask the question *why have it as a separate library?* The answer has two main parts:
|
||||||
|
1. [Flexibility / Extensibility](#flexibility--extensibility)
|
||||||
|
2. [Light Weight](#light-weight)
|
||||||
|
|
||||||
|
Not directly related to the rational for having it as a separate library but rather a consequence of it being a separate library is that testing becomes significantly more isolated and concentrated which hopefully means better coverage and overall utility of testing.
|
||||||
|
|
||||||
|
#### Flexibility / Extensibility
|
||||||
|
A very critical idea we embrace when doing any kind of development on security related code is that the development is never over. That is, security is an always moving target because attackers are forever finding new vulnerabilities or methods of compromising things.
|
||||||
|
|
||||||
|
As a direct consequence of embracing this idea, we're often trying to develop something that is long-lasting and reusable but flexible enough to deal with the concerns of the day. Or to put it another way, having mechanisms that allow for future extensibility.
|
||||||
|
|
||||||
|
#### Light Weight
|
||||||
|
In conjunction with the point about flexibility / extensibility above as reusable code that we want used in various different places making this code light weight and "portable" ws something we spent time being intentional about.
|
||||||
|
|
||||||
|
### JWKs and `node-jose`
|
||||||
|
The underlying technology of Keystores is [JSON Web Keys (JWKs)](https://datatracker.ietf.org/doc/html/rfc7517) and specifically the [`node-jose`](https://www.npmjs.com/package/node-jose)'s implementation of it ([`node-jose`](https://www.npmjs.com/package/node-jose) was chosen not just for JWKs support but also it's support for standards like [JSON Web Encryption (JWE)](https://datatracker.ietf.org/doc/html/rfc7516), [JSON Web Signatures (JWS)](https://datatracker.ietf.org/doc/html/rfc7515) and [JSON Web Tokens (JWTs)](https://datatracker.ietf.org/doc/html/rfc7519) which are all technologies used extensively throughout the [Bridgeman Accessible Auth library](https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth) for secure communications).
|
||||||
|
|
||||||
|
## Existing Keystore Types
|
||||||
|
*Note, this section is for easy reference but shouldn't be interpreted as authoritative in any way*
|
||||||
|
|
||||||
|
At time of writing there are several different Keystore types available:
|
||||||
|
- [File Keystore](https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth_keystore_file)
|
||||||
|
- [Vault Keystore (Hashicorp Vault)](https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth_keystore_vault)
|
||||||
|
- [Keyvault Keystore (Azure Key Vault)](https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth_keystore_keyvault)
|
||||||
|
|
||||||
|
It's worth noting our internal convention for package names that are downstream ("implement") this package is `ba-auth_keystore_<type>` where `<type>` is replaced by the type (`ba-auth_keystore` prefix being the name of this package).
|
||||||
|
|
||||||
|
## New Keystore Types
|
||||||
|
Flexibility to develop new Keystore types as/when needed or desired was something we took time and effor to support.
|
||||||
|
|
||||||
|
### Creating A New Keystore Type
|
||||||
|
To create a new Keystore type all you need to do is create a subclass of the abstract `BaseKeystore` class which can be imported from this module/package/library and register it on the `Keystore` constant also exported from this library.
|
||||||
|
|
||||||
|
That said, it's worth noting the most critical part of this is assigning a value to the protected `keystore` property. This is typed as an instance of `JWK` from [`node-jose`](https://www.npmjs.com/package/node-jose) which, in all honesty, isn't the easiest to implement because of choices that were made by those developers when it comes to making most of the methods synchronous instead of asynchronous which for I/O is kind of unfortunate.
|
||||||
|
|
||||||
|
### Registering A New Keystore Type
|
||||||
|
To register a new keystore type (once developed as described above) all you need to do is call `Keystore.addKeystoreType` (which `Keystore` similar to `BaseKeystore` is available from this library) and provide a "key" string (how it's identified) and either the class (provided it has a public constructor) or a factory method.
|
||||||
|
|
||||||
|
### Using The New Keystore Type
|
||||||
|
It's worth consulting the [Bridgeman Accessible Auth library](https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth) documentation as the more authoritative resource on how to use new keystore types. But at time of writing, and in principal, during setup of the Client, ResourceServer, Server or other object one of the options is a `keystoreType` string parameter (it varies a bit across the different objects) but if you use the same key that the new keystore type was registered with this will initialize the Keystore of the new type
|
||||||
36
jest.config.cjs
Normal file
36
jest.config.cjs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest/presets/default-esm',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
|
||||||
|
// Tell Jest where to look for source code and tests
|
||||||
|
roots: ['<rootDir>/src', '<rootDir>/tests'],
|
||||||
|
|
||||||
|
// Explicitly match files in the tests folder
|
||||||
|
testMatch: [
|
||||||
|
'<rootDir>/tests/**/*.test.ts',
|
||||||
|
'<rootDir>/tests/**/*.spec.ts'
|
||||||
|
],
|
||||||
|
|
||||||
|
// Tell Jest to treat .ts files as ESM modules
|
||||||
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
|
|
||||||
|
// Fixes the "Cannot find module" error
|
||||||
|
moduleNameMapper: {
|
||||||
|
// If the import starts with . or .. and ends with .js, strip the .js
|
||||||
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
|
|
||||||
|
// Map the library to mock file
|
||||||
|
// This tells Jest to load simple JS file instead of parsing the complex node_module
|
||||||
|
//'^@BridgemanAccessible/ba-auth$': '<rootDir>/tests/__mocks__/ba-auth.js'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Configure the transformer to enable ESM support
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
useESM: true,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
39
package.json
Normal file
39
package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "@BridgemanAccessible/ba-auth_keystore",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Library container Keystore fundamentals and type registry to be used in combination with the Bridgeman Accessible Auth Package",
|
||||||
|
"main": "index.js",
|
||||||
|
"types": "index.d.ts",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth_keystore.git"
|
||||||
|
},
|
||||||
|
"author": "Bridgeman Accessible<info@bridgemanaccessible.ca>",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"default": "./index.js",
|
||||||
|
"types": "./index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"@types/node-jose": "^1.1.13",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"jest": "^30.2.0",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@BridgemanAccessible/ba-logging": "^1.0.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"node-jose": "^2.2.0"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@1.22.22"
|
||||||
|
}
|
||||||
102
src/BaseKeystore.ts
Normal file
102
src/BaseKeystore.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import type { JWK as JWKTypes } from 'node-jose';
|
||||||
|
|
||||||
|
import { logMessage, LogLevel } from '@BridgemanAccessible/ba-logging';
|
||||||
|
|
||||||
|
/** Abstract class that provides helper functions like Express middleware to expose the keystore's public keys on the well-known endpoint. */
|
||||||
|
export abstract class BaseKeystore {
|
||||||
|
/** The well-known URI where the keystore's public keys are exposed */
|
||||||
|
public static readonly WELL_KNOWN_URI = '/.well-known/jwks.json';
|
||||||
|
|
||||||
|
/** The JOSE keystore object */
|
||||||
|
protected keystore: JWKTypes.KeyStore | null;
|
||||||
|
|
||||||
|
/** Create a new keystore */
|
||||||
|
constructor() {
|
||||||
|
this.keystore = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the JOSE keystore object
|
||||||
|
*
|
||||||
|
* @returns The JOSE keystore object
|
||||||
|
* @throws An error if the keystore is not ready
|
||||||
|
*/
|
||||||
|
getKeystore(): JWKTypes.KeyStore {
|
||||||
|
if(this.keystore == null) {
|
||||||
|
throw new Error('Keystore not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.keystore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a key from the keystore
|
||||||
|
*
|
||||||
|
* @param id The ID of the key to get
|
||||||
|
* @param filter An optional filter to apply to the key
|
||||||
|
* @returns The key from the keystore
|
||||||
|
* @throws An error if the keystore is not ready or if the key is not found
|
||||||
|
*/
|
||||||
|
getKey(id: string, filter?: JWKTypes.KeyStoreGetFilter): JWKTypes.Key {
|
||||||
|
return this.getKeystore().get(id, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide an express middleware function to handle exposing the keystore's public keys on the well-known endpoint.
|
||||||
|
*
|
||||||
|
* The following table describes response on the well-known endpoint:
|
||||||
|
*
|
||||||
|
* | Status Code | Description |
|
||||||
|
* | ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
|
* | 200 | The keystore's public keys were successfully retrieved and returned in the response body |
|
||||||
|
* | 500 | An error occurred while trying to retrieve the keystore's public keys (likely keystore isn't ready yet) |
|
||||||
|
*
|
||||||
|
* @param req The request object
|
||||||
|
* @param res The response object
|
||||||
|
* @param next The next function
|
||||||
|
*/
|
||||||
|
middleware(req: Request, res: Response, next: NextFunction) {
|
||||||
|
// Only care about requests that are trying to access the well-known endpoint
|
||||||
|
if(req.path === BaseKeystore.WELL_KNOWN_URI) {
|
||||||
|
// console.log(process.env.DEBUG_InTERSERVICE_COMMS);
|
||||||
|
if(typeof process.env.DEBUG_INTERSERVICE_COMMS !== 'undefined' && process.env.DEBUG_INTERSERVICE_COMMS === 'true') {
|
||||||
|
logMessage('Keystore middleware called', LogLevel.DEBUG);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note, because keystore setup is left up to any child classes that extend this base class,
|
||||||
|
// we need to check if the keystore is ready before trying to use it.
|
||||||
|
// If it's not ready, we should return an error response instead of trying to use the keystore and having it throw an error.
|
||||||
|
let keystore: JWKTypes.KeyStore;
|
||||||
|
try {
|
||||||
|
keystore = this.getKeystore();
|
||||||
|
}
|
||||||
|
catch(error) {
|
||||||
|
if(typeof process.env.DEBUG_INTERSERVICE_COMMS !== 'undefined' && process.env.DEBUG_INTERSERVICE_COMMS === 'true') {
|
||||||
|
logMessage('Keystore not ready', LogLevel.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(new Error('Keystore not ready'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the built-in node-jose `toJSON` function to convert the keystore to a JSON object that can be sent in the response
|
||||||
|
// Note, we omit the `exportPrivateKeys` parameter meaning it's set to false.
|
||||||
|
// This is what we want because we ONLY want to expose the public keys in the keystore, NOT the private keys.
|
||||||
|
const keystoreJson = keystore.toJSON();
|
||||||
|
|
||||||
|
if(typeof process.env.DEBUG_INTERSERVICE_COMMS !== 'undefined' && process.env.DEBUG_INTERSERVICE_COMMS === 'true') {
|
||||||
|
logMessage(`Keystore (keystore middleware): ${JSON.stringify(keystoreJson)}`, LogLevel.DEBUG);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the public keys in the keystore
|
||||||
|
res.status(200).json(keystoreJson);
|
||||||
|
|
||||||
|
// STOP processing the request because we've already sent a response.
|
||||||
|
// And we don't want to accidentally call next() and have another middleware or route handler try to send another response (which would cause an error because you can't send multiple responses to the same request)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to the next middleware
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Keystore.ts
Normal file
60
src/Keystore.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { BaseKeystore } from './BaseKeystore.js';
|
||||||
|
|
||||||
|
/** Subclass of BaseKeystore (using constructor) */
|
||||||
|
type BaseKeystoreConstructor = new (...args: any[]) => BaseKeystore;
|
||||||
|
/** Factory function that returns a BaseKeystore instance */
|
||||||
|
type BaseKeystoreFactoryFunc = (...args: any[]) => Promise<BaseKeystore> | BaseKeystore;
|
||||||
|
/** Union type for either a constructor or a factory function */
|
||||||
|
type BaseKeystoreInitFunc = BaseKeystoreConstructor | BaseKeystoreFactoryFunc;
|
||||||
|
|
||||||
|
/** Type guard to check if a function is a factory function */
|
||||||
|
function isKeystoreFactoryFunc(func: BaseKeystoreInitFunc): func is BaseKeystoreFactoryFunc {
|
||||||
|
return typeof func === 'function' && func.prototype === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registry pattern for managing different types of keystores */
|
||||||
|
class KeystoreRegistry {
|
||||||
|
private KeystoreTypes: Map<string, BaseKeystoreInitFunc>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.KeystoreTypes = new Map<string, BaseKeystoreInitFunc>();
|
||||||
|
}
|
||||||
|
|
||||||
|
addKeystoreType<T extends BaseKeystoreInitFunc>(type: string, keystore: T): void {
|
||||||
|
this.KeystoreTypes.set(type, keystore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to get an instance of the given keystore type
|
||||||
|
*
|
||||||
|
* @param type The type of keystore to get (e.g., 'azure', 'hashicorp', 'file')
|
||||||
|
* @param args Additional arguments to pass to the keystore constructor or factory function
|
||||||
|
* @return An instance of the requested keystore type
|
||||||
|
* @throws Error if the requested keystore type is not found in the registry
|
||||||
|
*/
|
||||||
|
async getKeystore(type: string, ...args: any[]) {
|
||||||
|
if(!this.KeystoreTypes.has(type)) {
|
||||||
|
throw new Error(`Keystore type '${type}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the factory function for the current keystore type
|
||||||
|
const keystoreFactoryFunc = this.KeystoreTypes.get(type);
|
||||||
|
|
||||||
|
if(typeof keystoreFactoryFunc === 'undefined' || keystoreFactoryFunc === null) {
|
||||||
|
throw new Error(`Keystore type '${type}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keystoreFactoryFunc === null) {
|
||||||
|
throw new Error('No keystore type set');
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isKeystoreFactoryFunc(keystoreFactoryFunc)) {
|
||||||
|
// If the factory function is a factory function, call it to get an instance of the keystore
|
||||||
|
return await keystoreFactoryFunc(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new keystoreFactoryFunc(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Keystore = new KeystoreRegistry();
|
||||||
158
tests/BaseKeystore.test.ts
Normal file
158
tests/BaseKeystore.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
|
||||||
|
|
||||||
|
import type { BaseKeystore as BaseKeystoreType } from '../src/BaseKeystore.js';
|
||||||
|
|
||||||
|
// Mock the logging library
|
||||||
|
jest.unstable_mockModule('@BridgemanAccessible/ba-logging', () => ({
|
||||||
|
logMessage: jest.fn(),
|
||||||
|
LogLevel: {
|
||||||
|
DEBUG: 'DEBUG',
|
||||||
|
ERROR: 'ERROR'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('BaseKeystore', () => {
|
||||||
|
/** Env management pattern (save/restore existing environment variables) */
|
||||||
|
const OLD_ENV = process.env;
|
||||||
|
|
||||||
|
/** Instance of the BaseKeystore class (needed to access static members like WELL_KNOWN_URI) */
|
||||||
|
let BaseKeystore: typeof BaseKeystoreType;
|
||||||
|
/** Instance of the concrete subclass for testing (Note, the typing gets kind of weird here because of loading mechanics - see `beforeEach` block) */
|
||||||
|
let keystoreInstance: InstanceType<typeof BaseKeystore> & { setInternalKeystore: (ks: any) => void };
|
||||||
|
/** Mock Express request object */
|
||||||
|
let mockReq: Partial<Request & { /* Make path NOT read-only */ path: string }>;
|
||||||
|
/** Mock Express response object */
|
||||||
|
let mockRes: Partial<Response>;
|
||||||
|
/** Mock Express next function */
|
||||||
|
let mockNext: NextFunction;
|
||||||
|
/** Mock Node-Jose keystore */
|
||||||
|
let mockJoseStore: any;
|
||||||
|
/** Mock logging module */
|
||||||
|
let mockLogging: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Make a copy of the existing environment variables to restore after tests
|
||||||
|
// (important for not affecting other tests that might run in the same process)
|
||||||
|
process.env = { ...OLD_ENV };
|
||||||
|
|
||||||
|
// Reset Process Env
|
||||||
|
process.env.DEBUG_INTERSERVICE_COMMS = 'false';
|
||||||
|
|
||||||
|
// Because of ESM stuff and mocking out dependencies,
|
||||||
|
// we need to load the BaseKeystore class asynchronously in the beforeEach block.
|
||||||
|
// Instead of at the top level of the test file.
|
||||||
|
const module = await import('../src/BaseKeystore.js');
|
||||||
|
BaseKeystore = module.BaseKeystore;
|
||||||
|
|
||||||
|
// Concrete implementation for testing abstract class
|
||||||
|
// Note, the definition needs to be done here in the beforeEach block after we load the BaseKeystore class,
|
||||||
|
// because it needs to extend the BaseKeystore class that we load here.
|
||||||
|
class TestKeystore extends BaseKeystore {
|
||||||
|
// Helper to manually set the internal keystore for testing
|
||||||
|
public setInternalKeystore(ks: any) {
|
||||||
|
this.keystore = ks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keystoreInstance = new TestKeystore();
|
||||||
|
|
||||||
|
// Mock Express objects
|
||||||
|
mockReq = { path: '/' };
|
||||||
|
mockRes = {
|
||||||
|
status: jest.fn<(...args: any[]) => Response>().mockReturnThis(),
|
||||||
|
json: jest.fn<(...args: any[]) => Response>().mockReturnThis(),
|
||||||
|
};
|
||||||
|
mockNext = jest.fn();
|
||||||
|
|
||||||
|
// Mock Node-Jose Store
|
||||||
|
mockJoseStore = {
|
||||||
|
toJSON: jest.fn().mockReturnValue({ keys: [] }),
|
||||||
|
get: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
mockLogging = await import('@BridgemanAccessible/ba-logging');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getKeystore()', () => {
|
||||||
|
it('should throw error if keystore is null', () => {
|
||||||
|
expect(() => keystoreInstance.getKeystore()).toThrow('Keystore not ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the keystore if initialized', () => {
|
||||||
|
keystoreInstance.setInternalKeystore(mockJoseStore);
|
||||||
|
expect(keystoreInstance.getKeystore()).toBe(mockJoseStore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getKey()', () => {
|
||||||
|
it('should call get on the underlying node-jose store', () => {
|
||||||
|
keystoreInstance.setInternalKeystore(mockJoseStore);
|
||||||
|
keystoreInstance.getKey('key-id', { kty: 'RSA' });
|
||||||
|
expect(mockJoseStore.get).toHaveBeenCalledWith('key-id', { kty: 'RSA' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('middleware()', () => {
|
||||||
|
it('should call next() immediately if path is not .well-known URI', () => {
|
||||||
|
mockReq.path = '/some/other/path';
|
||||||
|
keystoreInstance.middleware(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
|
expect(mockNext).toHaveBeenCalled();
|
||||||
|
expect(mockRes.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call next(Error) if accessing .well-known but keystore is not ready', () => {
|
||||||
|
mockReq.path = BaseKeystore.WELL_KNOWN_URI;
|
||||||
|
|
||||||
|
keystoreInstance.middleware(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(expect.any(Error));
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ message: 'Keystore not ready' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 200 and JSON public keys if ready', () => {
|
||||||
|
mockReq.path = BaseKeystore.WELL_KNOWN_URI;
|
||||||
|
keystoreInstance.setInternalKeystore(mockJoseStore);
|
||||||
|
|
||||||
|
keystoreInstance.middleware(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ keys: [] });
|
||||||
|
|
||||||
|
// Ensure private keys are NOT exported (toJSON should be called with false or no args)
|
||||||
|
expect(mockJoseStore.toJSON).toHaveBeenCalled();
|
||||||
|
expect(mockNext).not.toHaveBeenCalled(); // Should stop processing
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log debug messages when env var is set', () => {
|
||||||
|
process.env.DEBUG_INTERSERVICE_COMMS = 'true';
|
||||||
|
|
||||||
|
mockReq.path = BaseKeystore.WELL_KNOWN_URI;
|
||||||
|
keystoreInstance.setInternalKeystore(mockJoseStore);
|
||||||
|
|
||||||
|
keystoreInstance.middleware(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
|
expect(mockLogging.logMessage).toHaveBeenCalledTimes(2); // Start of middleware + JSON log
|
||||||
|
expect(mockLogging.logMessage).toHaveBeenCalledWith(expect.stringContaining('Keystore middleware called'), 'DEBUG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log error messages when env var is set and keystore fails', () => {
|
||||||
|
process.env.DEBUG_INTERSERVICE_COMMS = 'true';
|
||||||
|
|
||||||
|
mockReq.path = BaseKeystore.WELL_KNOWN_URI;
|
||||||
|
// Keystore is NOT set here
|
||||||
|
|
||||||
|
keystoreInstance.middleware(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
|
expect(mockLogging.logMessage).toHaveBeenCalledWith('Keystore not ready', 'ERROR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore original environment variables after all tests are done
|
||||||
|
process.env = OLD_ENV;
|
||||||
|
})
|
||||||
|
});
|
||||||
99
tests/Keystore.test.ts
Normal file
99
tests/Keystore.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
|
||||||
|
|
||||||
|
import type { Keystore as KeystoreType } from '../src/Keystore.js';
|
||||||
|
import type { BaseKeystore as BaseKeystoreType } from '../src/BaseKeystore.js';
|
||||||
|
|
||||||
|
describe('KeystoreRegistry', () => {
|
||||||
|
let Keystore: typeof KeystoreType;
|
||||||
|
let BaseKeystore: typeof BaseKeystoreType;
|
||||||
|
let MockKeystoreImpl: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Keystore.test.ts
|
||||||
|
const keystoreModule = await import('../src/Keystore.js');
|
||||||
|
Keystore = keystoreModule.Keystore;
|
||||||
|
|
||||||
|
const baseKeystoreModule = await import ('../src/BaseKeystore.js');
|
||||||
|
BaseKeystore = baseKeystoreModule.BaseKeystore;
|
||||||
|
|
||||||
|
// Mock implementation of BaseKeystore for the registry
|
||||||
|
MockKeystoreImpl = class MockKeystoreImpl extends BaseKeystore {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: Generates a unique key to avoid singleton collisions
|
||||||
|
const getUniqueType = () => `type_${Math.random().toString(36).substring(7)}`;
|
||||||
|
|
||||||
|
describe('addKeystoreType & getKeystore', () => {
|
||||||
|
it('should throw error if requesting a non-existent type', async () => {
|
||||||
|
await expect(Keystore.getKeystore('non-existent-type'))
|
||||||
|
.rejects.toThrow("Keystore type 'non-existent-type' not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register and retrieve a Class Constructor', async () => {
|
||||||
|
const type = getUniqueType();
|
||||||
|
|
||||||
|
// Register the class itself
|
||||||
|
Keystore.addKeystoreType(type, MockKeystoreImpl);
|
||||||
|
|
||||||
|
const instance = await Keystore.getKeystore(type);
|
||||||
|
expect(instance).toBeInstanceOf(MockKeystoreImpl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register and retrieve a Factory Function (Synchronous)', async () => {
|
||||||
|
const type = getUniqueType();
|
||||||
|
const factory = () => new MockKeystoreImpl();
|
||||||
|
|
||||||
|
Keystore.addKeystoreType(type, factory);
|
||||||
|
|
||||||
|
const instance = await Keystore.getKeystore(type);
|
||||||
|
expect(instance).toBeInstanceOf(MockKeystoreImpl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register and retrieve a Factory Function (Async)', async () => {
|
||||||
|
const type = getUniqueType();
|
||||||
|
|
||||||
|
// Factory returns a Promise
|
||||||
|
const asyncFactory = async () => new MockKeystoreImpl();
|
||||||
|
Keystore.addKeystoreType(type, asyncFactory);
|
||||||
|
|
||||||
|
const instance = await Keystore.getKeystore(type);
|
||||||
|
expect(instance).toBeInstanceOf(MockKeystoreImpl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass arguments to the constructor', async () => {
|
||||||
|
const type = getUniqueType();
|
||||||
|
|
||||||
|
// Create a specific mock that accepts args
|
||||||
|
class ArgKeystore extends BaseKeystore {
|
||||||
|
public args: any[];
|
||||||
|
|
||||||
|
constructor(...args: any[]) {
|
||||||
|
super();
|
||||||
|
this.args = args;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Keystore.addKeystoreType(type, ArgKeystore);
|
||||||
|
|
||||||
|
const instance = await Keystore.getKeystore(type, 'arg1', 123) as ArgKeystore;
|
||||||
|
expect(instance.args).toEqual(['arg1', 123]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass arguments to the factory function', async () => {
|
||||||
|
const type = getUniqueType();
|
||||||
|
const factorySpy = jest.fn<(...args: any[]) => InstanceType<typeof MockKeystoreImpl>>().mockReturnValue(new MockKeystoreImpl());
|
||||||
|
|
||||||
|
Keystore.addKeystoreType(type, factorySpy);
|
||||||
|
|
||||||
|
await Keystore.getKeystore(type, 'factoryArg', 999);
|
||||||
|
expect(factorySpy).toHaveBeenCalledWith('factoryArg', 999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isKeystoreFactoryFunc', () => {
|
||||||
|
// Since this function isn't exported directly, we test it via behavior
|
||||||
|
// implicitly in the tests above, but if you wanted to test it explicitly,
|
||||||
|
// you would need to export it from the file.
|
||||||
|
// For now, the factory vs constructor behavior is covered by the previous two tests.
|
||||||
|
});
|
||||||
|
});
|
||||||
20
tsconfig.build.json
Normal file
20
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
// Visit https://aka.ms/tsconfig to read more about this file
|
||||||
|
"compilerOptions": {
|
||||||
|
// File Layout
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
|
||||||
|
"types": [],
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"./src/**/*.ts",
|
||||||
|
"./src/**/*.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
50
tsconfig.json
Normal file
50
tsconfig.json
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
// Visit https://aka.ms/tsconfig to read more about this file
|
||||||
|
"compilerOptions": {
|
||||||
|
// File Layout
|
||||||
|
// "rootDir": "./src",
|
||||||
|
// "outDir": "./dist",
|
||||||
|
|
||||||
|
// Environment Settings
|
||||||
|
// See also https://aka.ms/tsconfig/module
|
||||||
|
"module": "nodenext",
|
||||||
|
"target": "esnext",
|
||||||
|
"types": ["node", "jest"],
|
||||||
|
// For nodejs:
|
||||||
|
// "lib": ["esnext"],
|
||||||
|
// "types": ["node"],
|
||||||
|
// and npm install -D @types/node
|
||||||
|
|
||||||
|
// Other Outputs
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
|
||||||
|
// Stricter Typechecking Options
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
|
||||||
|
// Style Options
|
||||||
|
// "noImplicitReturns": true,
|
||||||
|
// "noImplicitOverride": true,
|
||||||
|
// "noUnusedLocals": true,
|
||||||
|
// "noUnusedParameters": true,
|
||||||
|
// "noFallthroughCasesInSwitch": true,
|
||||||
|
// "noPropertyAccessFromIndexSignature": true,
|
||||||
|
|
||||||
|
// Recommended Options
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"],
|
||||||
|
"include": [
|
||||||
|
"./src/**/*.ts",
|
||||||
|
"./src/**/*.tsx",
|
||||||
|
"./tests/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue