Initial Commmit
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 38s

This commit is contained in:
Alan Bridgeman 2026-02-18 12:48:25 -06:00
commit 170b1a0cec
14 changed files with 4777 additions and 0 deletions

View 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
View 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
View 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.

8
README.md Normal file
View file

@ -0,0 +1,8 @@
# Azure Key Vault based Implementation of bridgeman Accessible Auth Keystore
This is the [Azure Key Vault](https://azure.microsoft.com/en-us/products/key-vault) implementation of the Bridgeman Accessible Auth Keystore.
That is, this module/package/library stores and retrieves JSON Web Keys ("keys") from an Azure Key Vault. Which means keys managed in this way should be secure. As a cloud product offered by Microsoft, Azure Key Vault should offer robust, secure key storage.
For more details on the Keystore concept itself or "upstream effects" see the [Keystore Library README](https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-auth_keystore/src/branch/main/README.md).
This module/package/library includes a testing suite to verify functionality. It can be run using the `yarn test` command in any terminal/shell that supports Yarn. This test suite is also run as part of the public CI/CD automation on push.

36
jest.config.cjs Normal file
View 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,
}
]
}
};

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "@BridgemanAccessible/ba-auth_keystore_keyvault",
"version": "1.0.0",
"description": "An Azure Key Vault based Keystore implementation for use 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_keyvault.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/jest": "^30.0.0",
"@types/node": "^25.2.3",
"@types/node-forge": "^1.3.14",
"@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-auth_keystore": "^1.0.1",
"@BridgemanAccessible/ba-logging": "^1.0.1",
"@azure/identity": "^4.13.0",
"@azure/keyvault-keys": "^4.10.0",
"node-jose": "^2.2.0"
},
"packageManager": "yarn@1.22.22"
}

View file

@ -0,0 +1,40 @@
import { ClientSecretCredential } from "@azure/identity";
import { KeyClient } from '@azure/keyvault-keys';
/**
* Easy to use wrapper for Azure Key Vault credentials
*/
export class AzureKeyvaultCredentials {
private vaultName: string;
private vaultClientId: string;
private vaultClientSecret: string;
private vaultTenantId: string;
get vaultUrl(): string {
return `https://${this.vaultName}.vault.azure.net`;
}
get vaultCredentials(): ClientSecretCredential {
return new ClientSecretCredential(this.vaultTenantId, this.vaultClientId, this.vaultClientSecret);
}
get keyClient(): KeyClient {
return new KeyClient(this.vaultUrl, this.vaultCredentials);
}
constructor(vaultName: string, vaultClientId: string, vaultClientSecret: string, vaultTenantId: string) {
this.vaultName = vaultName;
this.vaultClientId = vaultClientId;
this.vaultClientSecret = vaultClientSecret;
this.vaultTenantId = vaultTenantId;
}
getCredentials() {
return {
name: this.vaultName,
client_id: this.vaultClientId,
client_secret: this.vaultClientSecret,
tenant_id: this.vaultTenantId
};
}
}

477
src/KeyvaultKeys.ts Normal file
View file

@ -0,0 +1,477 @@
import NodeJose from 'node-jose';
import type { JWK as JWKTypes } from 'node-jose';
import type { JsonWebKey, PollerLike, PollOperationState, DeletedKey, KeyVaultKey } from '@azure/keyvault-keys';
// @ts-ignore
import { registry } from 'node-jose/lib/jwk/keystore.js';
// @ts-ignore
import merge from 'node-jose/lib/util/merge.js';
import type { Bytes } from 'node-forge';
import { pki } from 'node-forge';
import logMessage, { LogLevel } from '@BridgemanAccessible/ba-logging';
import { AzureKeyvaultCredentials } from './KeyvaultCredentials.js';
const { JWK } = NodeJose;
type JWKRsaFactory = { generate: (size: number) => Promise<pki.rsa.PrivateKey> };
type JWKOctetFactory = { generate: (size: number) => Promise<Bytes> };
type JWKEcFactory = { generate: (size: 'P-256' | 'P-384' | 'P-521') => Promise<{ crv: 'P-256' | 'P-384' | 'P-521', x: Buffer<ArrayBuffer>, y: Buffer<ArrayBuffer>, d: Buffer<ArrayBuffer>}> };
/** Azure Key Vault implementation of the JWK.KeyStore interface */
export class KeyvaultKeys implements JWKTypes.KeyStore {
/** Azure Key Vault credentials object (including a client object) */
private readonly KEY_VAULT_CRED: AzureKeyvaultCredentials;
/**
* The list of keys in the KeyStore
*
* This is necessary because `node-jose`'s definition of the `JWK.KeyStore` interface (which we're implementing for practical reasons) forces methods like `all` and `get` to be synchronous.
* This is in direct conflict with the asynchronous nature of the `@azure/keyvault-keys` library for interacting with the Azure Key Vault.
* To get around this we initially asynchronously load the keys from the Azure Key Vault and store them in this local class variable.
* And then if someone adds a key we BOTH save it to the Azure Key Vault AND add it to the local class variable.
* This way when we call `all` or `get` which are synchronous, we can just return this variable (or subsets of it).
*
* While this does mean there could be some inconsistency between this variable and the Azure Key Vault,
* particularly if the keys in the Azure Key Vault are modified directly and the container/process running this library isn't restarted,
* this is a trade-off we almost need to make because the complexity of making the `@azure/keyvault-keys` library work synchronously
* (or the `node-jose` library work asynchronously) is too high.
*/
private keys: JWKTypes.Key[];
/** Create a new KeyvaultKeys (Azure specific implementation of the JWK.KeyStore) object */
constructor(creds: AzureKeyvaultCredentials) {
this.KEY_VAULT_CRED = creds;
this.keys = [];
}
/**
* Convert an Azure Key Vault key to a Node.js JOSE raw key
*
* @param azKey The Azure Key Vault key to convert
* @returns The Node.js JOSE raw key equivalent to the Azure Key Vault key
*/
private convertAzKeyVaultKeyToNodeJoseRawKey(azKey: JsonWebKey): JWKTypes.RawKey {
// Initial conversion of the Azure Key Vault key to a Node.js JOSE raw key
// These are the elements that are easily converted
// kty (key type), kid (key ID), n (modulus), e (exponent)
const nodeJoseKey: Partial<JWKTypes.RawKey> = {
kty: azKey.kty as string,
kid: azKey.kid as string,
n: azKey.n?.toString() as string,
e: azKey.e?.toString() as string
};
// Example logic for 'use'
if (azKey.keyOps) {
if (azKey.keyOps.some(op => ['encrypt', 'decrypt'].includes(op))) {
nodeJoseKey.use = 'enc';
}
else if (azKey.keyOps.some(op => ['sign', 'verify'].includes(op))) {
nodeJoseKey.use = 'sig';
}
}
// Example logic for 'alg'
// This is a simplified mapping. Actual mapping may need to consider more factors.
switch (azKey.kty) {
case 'RSA':
nodeJoseKey.alg = 'RS256'; // Assuming RS256 for simplicity. Actual algorithm might depend on key usage and capabilities.
break;
case 'EC':
if (azKey.crv) {
switch (azKey.crv) {
case 'P-256':
nodeJoseKey.alg = 'ES256';
break;
case 'P-384':
nodeJoseKey.alg = 'ES384';
break;
case 'P-521':
nodeJoseKey.alg = 'ES512';
break;
// Add more cases as needed
}
}
break;
case 'oct':
nodeJoseKey.alg = 'HS256'; // Simplified assumption for symmetric keys
break;
// Add more cases for other 'kty' values as needed
}
return nodeJoseKey as JWKTypes.RawKey;
}
/**
* Get a key from the KeyStore based on the key ID (kid) or options provided
*
* @param kid The key ID (kid) of the key to get
* @param filter The filter to apply to the key
*/
get(kid: string, filter?: JWKTypes.KeyStoreGetFilter): JWKTypes.Key;
get(options: JWKTypes.KeyStoreGetOptions): JWKTypes.Key;
get(kidOrOptions: string | JWKTypes.KeyStoreGetOptions, filter?: JWKTypes.KeyStoreGetFilter): JWKTypes.Key {
let kid: string;
if(typeof kidOrOptions === 'string') {
kid = kidOrOptions;
}
else {
const options = kidOrOptions;
kid = options.kid;
}
/*let syncLock = false;
let nodeJoseKey: JWKTypes.Key | undefined;
this.KEY_VAULT_CRED.keyClient.getKey(kid)
.then(
async (key) => {
nodeJoseKey = await JWK.asKey(this.convertAzKeyVaultKeyToNodeJoseRawKey(key.key as JsonWebKey));
}
)
.finally(() => { syncLock = true; });
// TODO: Find an alternative non-blocking way to wait for the promise to resolve
//
// Unfortunately, because there is a fundamental mismatch between the async nature of the Azure Key Vault SDK and the synchronous nature of the node-jose library, we need to block the thread until the promise resolves.
// Despite ChatGPT gas-lighting me on if the `node-jose` library is synchronous or not, the fact remains that the `JWK.KeyStore.get` function has a synchronous (non-Promise) return type that this way I achieve.
while(!syncLock) {}*/
// Get the key from the local class variable
const nodeJoseKey = this.keys.find((key) => key.kid === kid);
logMessage(`Returned key: ${JSON.stringify(nodeJoseKey)}`, LogLevel.DEBUG);
if(typeof nodeJoseKey === 'undefined') {
logMessage(`Key ${kid} not found in the Azure Key Vault`, LogLevel.ERROR);
throw new Error(`Key ${kid} not found`);
}
return nodeJoseKey as JWKTypes.Key;
}
/**
* Add a key to the KeyStore
*
* @param key The key to add to the KeyStore
*/
async add(key: JWKTypes.RawKey): Promise<JWKTypes.Key>;
async add(key: string | object | JWKTypes.Key | Buffer, form?: "json" | "private" | "pkcs8" | "public" | "spki" | "pkix" | "x509" | "pem", extras?: Record<string, any>): Promise<JWKTypes.Key>;
async add(key: string | object | JWKTypes.Key | Buffer | JWKTypes.RawKey, form?: "json" | "private" | "pkcs8" | "public" | "spki" | "pkix" | "x509" | "pem", extras?: Record<string, any>): Promise<JWKTypes.Key> {
// Convert the key to a JWK.Key object
const jwk = await JWK.asKey(key, form, extras);
// Set the Azure Key Vault key options
const options: Record<string, any> = {};
// Setup the proper key operations based on the 'use' field of the JWK
switch(jwk.use) {
case 'sig':
options['keyOps'] = ['sign', 'verify'];
break;
case 'enc':
options['keyOps'] = ['encrypt', 'decrypt'];
break;
}
// Add the key to the Azure Key Vault
await this.KEY_VAULT_CRED.keyClient.createKey(jwk.kid, jwk.kty, options);
this.keys.push(jwk);
return jwk;
}
/**
* Remove a key from the KeyStore
*
* @param key The key to remove from the KeyStore
*/
remove(key: JWKTypes.Key): void {
const kid = key.kid;
// Remove the key from the local class variable
this.keys.splice(this.keys.findIndex(k => k.kid === kid), 1);
//let syncLock = false;
let deletePoller: PollerLike<PollOperationState<DeletedKey>, DeletedKey>
// Remove the key from the Azure Key Vault
this.KEY_VAULT_CRED.keyClient.beginDeleteKey(kid).then(poller => deletePoller = poller)/*.finally(() => { syncLock = true; });*/
//while(!syncLock) {}
// It's unclear if I actually need to wait for the delete operation to complete
// while(!deletePoller.isDone()) {}
}
/**
* Get all keys in the KeyStore or those that match the filter provided
*
* @param filter Filter to apply to the list of keys
* @returns The list of keys available in the KeyStore that match the filter (if provided) or all keys if no filter is provided
*/
all(filter?: JWKTypes.KeyStoreGetFilter): JWKTypes.Key[] {
logMessage('Getting all keys from the Hashicorp Vault...', LogLevel.DEBUG);
/*let syncLock = false;
let keys: JWKTypes.Key[] = [];
// Get the keys from the Azure Key Vault
new Promise<void>(
async (resolve, reject) => {
try {
// Loop over the keys in the Azure Key Vault
for await (const keyProperties of this.KEY_VAULT_CRED.keyClient.listPropertiesOfKeys()) {
// For each key name we find in the Azure Key Vault, get the key and add it to the list of keys
const key = this.get(keyProperties.name);
keys.push(key);
}
resolve();
}
catch(err) {
reject(err);
}
}
).finally(() => { syncLock = true; });
// We need to block the thread until the promise resolves
while(!syncLock) {}*/
const keys = this.keys;
logMessage(`Returned keys: ${JSON.stringify(keys)}`, LogLevel.DEBUG);
// Filter the keys based on the filter object provided
const filteredKeys = keys.filter((key) => {
// Check if the `alg` (algorithm) filtering is set and if it matches the current key
if(typeof filter?.alg !== 'undefined' && key.alg !== filter.alg) {
return false;
}
// Check if the `kty` (key type) filtering is set and if it matches the current key
if(typeof filter?.kty !== 'undefined' && key.kty !== filter.kty) {
return false;
}
// Check if the `use` filtering is set and if it matches the current key
if(typeof filter?.use !== 'undefined' && key.use !== filter.use) {
return false;
}
return true;
});
return filteredKeys;
}
toJSON(exportPrivate?: boolean): object {
var keys: object[] = [];
this.all().forEach((key) => {
keys.push(key.toJSON(exportPrivate));
});
logMessage(`KeyStore JSON: ${JSON.stringify({ keys })}`, LogLevel.DEBUG);
return { keys: keys };
}
generate(kty: 'RSA' | 'oct' | 'EC', size?: 'P-256' | 'P-384' |'P-521' | number, props?: { kty?: string, [key: string]: any }): Promise<JWKTypes.Key> {
// Set the default key size if not specified
if(typeof size === 'undefined' && kty !== 'EC') {
size = 2048;
}
else if(typeof size === 'undefined' && kty === 'EC') {
size = 'P-256';
}
// Throw an error if the key type (kty) is not supported with the specified size
if(kty !== 'EC' && typeof size === 'string') {
logMessage(`Key size specified is invalid: ${size}`, LogLevel.ERROR);
throw new Error('size must be an integer for RSA and oct key types');
}
logMessage(`Generating a new ${kty} key with size ${size}...`, LogLevel.DEBUG);
logMessage(`Key properties: ${JSON.stringify(props)}`, LogLevel.DEBUG);
// Get the key "factory" from the registry based on the key type (kty)
//
// This essentially gets the proper instance of:
// - JWKRsaFactory (node-jose/lib/jwk/rsakey),
// - JWKOctetFactory (node-jose/lib/jwk/octkey)
// - or JWKEcFactory (node-jose/lib/jwk/eckey)
//
// Use the key "factory" to generate a new key of the specified size
let promise: Promise<pki.rsa.PrivateKey | Bytes | { crv: 'P-256' | 'P-384' | 'P-521', x: Buffer<ArrayBuffer>, y: Buffer<ArrayBuffer>, d: Buffer<ArrayBuffer> }>;
switch(kty) {
case 'RSA':
try {
const rsaFactory = registry.get(kty) as JWKRsaFactory;
promise = rsaFactory.generate(size as number);
}
catch(error) {
logMessage(`Error generating RSA key: ${error}`, LogLevel.ERROR);
promise = Promise.reject(error);
}
break;
case 'oct':
try {
const octFactory = registry.get(kty) as JWKOctetFactory;
promise = octFactory.generate(size as number);
}
catch(error) {
logMessage(`Error generating oct key: ${error}`, LogLevel.ERROR);
promise = Promise.reject(error);
}
break;
case 'EC':
try {
const ecFactory = registry.get(kty) as JWKEcFactory;
promise = ecFactory.generate(size as 'P-256' | 'P-384' | 'P-521');
}
catch(error) {
logMessage(`Error generating EC key: ${error}`, LogLevel.ERROR);
promise = Promise.reject(error);
}
break;
default:
return Promise.reject(new Error("unsupported key type"));
}
var self = this
return promise.then((generatedKey: pki.rsa.PrivateKey | Bytes | { crv: 'P-256' | 'P-384' | 'P-521', x: Buffer<ArrayBuffer>, y: Buffer<ArrayBuffer>, d: Buffer<ArrayBuffer> }) => {
logMessage(`Generated key: ${JSON.stringify(generatedKey)}`, LogLevel.DEBUG);
// merge props and the key type (kty) into the JWK object
const jwk = merge(props, generatedKey, { kty: kty }) as string | object | JWKTypes.Key | Buffer | JWKTypes.RawKey;
logMessage(`Generated (raw) JWK: ${JSON.stringify(jwk)}`, LogLevel.DEBUG);
// Add the key to the KeyStore
return self.add(jwk);
});
}
/**
* Attempt to load any keys from the Azure Key Vault into the keystore (local class variable)
*
* This is necessary because `node-jose`'s definition of the `JWK.KeyStore` interface (which we're implementing for practical reasons) forces methods like `all` and `get` to be synchronous.
* This is in direct conflict with the asynchronous nature of the `@azure/keyvault-keys` library for interacting with the Azure Key Vault.
* To get around this we initially asynchronously load the keys (this method) and store them in a local class variable.
* And then if someone adds a key we BOTH save it to the Azure Key Vault AND add it to the local class variable.
* This way when we call `all` or `get` which are synchronous, we can just return the local class variable (or subset of it).
*
* While this does mean there could be some inconsistency between the local class variable and the Azure Key Vault,
* particularly if the keys in the Azure Key Vault are modified directly and the container running this library isn't restarted,
* this is a trade-off we almost need to make because the complexity of making the `@azure/keyvault-keys` library work synchronously
* (or the `node-jose` library work asynchronously) is too high.
*
* @returns The list of keys loaded from the Azure Key Vault
*/
async loadKeys() {
// List the keys in the Azure Key Vault
try {
for await (const key of this.KEY_VAULT_CRED.keyClient.listPropertiesOfKeys()) {
await (
async () => {
const azKey = await this.KEY_VAULT_CRED.keyClient.getKey(key.name);
if (typeof azKey.key !== 'undefined') {
const rawJWK = this.convertAzKeyVaultKeyToNodeJoseRawKey(azKey.key);
this.keys.push(await JWK.asKey(rawJWK));
}
}
)();
}
}
catch(error) {
logMessage(`Error listing keys in the Azure Key Vault: ${error}`, LogLevel.ERROR);
return [];
}
logMessage(`Key list from Azure Key Vault: ${JSON.stringify(this.keys)}`, LogLevel.DEBUG);
return this.keys;
}
/**
* Setup some initial keys for the keystore
*
* This sets up two new RSA key pairs. One for signing and the other for encryption.
*/
async setupInitialKeys() {
// Generate a new RSA key pair for signing
const signingKey = await this.generate('RSA', 2048, { alg: 'RS256', use: 'sig' });
// Generate a new RSA key pair for encryption
const encryptionKey = await this.generate('RSA', 2048, { /*alg: 'RS-OAEP', enc: 'A128CBC-HS256',*/ use: 'enc' });
}
/**
* Static factory method to create a new KeyvaultKeys (Azure Key Vault specific implementation of the `JWK.KeyStore` interface) object
*
* Note, either the values need to be explicitly provided via the parameter OR the following environment variables must be set:
*
* | Variable Name | Description |
* | ----------------------- | ------------------------------------------------------------------------ |
* | KEY_VAULT_TENANT_ID | The Azure AD Tenant ID of the tenant that contains the Azure Key Vault |
* | KEY_VAULT_CLIENT_ID | The Client ID of the application that can accsss the Azure Key Vault |
* | KEY_VAULT_CLIENT_SECRET | The Client Secret of the application that can access the Azure Key Vault |
* | KEY_VAULT_URL | The URL of the Azure Key Vault |
*/
static async init(
envVars: {
KEY_VAULT_NAME: string,
KEY_VAULT_CLIENT_ID: string,
KEY_VAULT_CLIENT_SECRET: string,
KEY_VAULT_TENANT_ID: string
} = {
KEY_VAULT_NAME: process.env.KEY_VAULT_NAME as string,
KEY_VAULT_CLIENT_ID: process.env.KEY_VAULT_CLIENT_ID as string,
KEY_VAULT_CLIENT_SECRET: process.env.KEY_VAULT_CLIENT_SECRET as string,
KEY_VAULT_TENANT_ID: process.env.KEY_VAULT_TENANT_ID as string
}
) {
// Verify that the needed variables are set
if(typeof envVars.KEY_VAULT_NAME === 'undefined' || envVars.KEY_VAULT_NAME.trim().length === 0) {
logMessage('The KEY_VAULT_NAME variable must be set to use an Azure Key Vault as the JWK.KeyStore', LogLevel.ERROR);
throw new Error('The KEY_VAULT_NAME variable must be set to use an Azure Key Vault as the JWK.KeyStore');
}
const vaultName = envVars.KEY_VAULT_NAME;
if(typeof envVars.KEY_VAULT_CLIENT_ID === 'undefined' || envVars.KEY_VAULT_CLIENT_ID.trim().length === 0) {
logMessage('The KEY_VAULT_CLIENT_ID variable must be set to use an Azure Key Vault as the JWK.KeyStore', LogLevel.ERROR);
throw new Error('The KEY_VAULT_CLIENT_ID variable must be set to use an Azure Key Vault as the JWK.KeyStore');
}
const clientId = envVars.KEY_VAULT_CLIENT_ID;
if(typeof envVars.KEY_VAULT_CLIENT_SECRET === 'undefined' || envVars.KEY_VAULT_CLIENT_SECRET.trim().length === 0) {
logMessage('The KEY_VAULT_CLIENT_SECRET variable must be set to use an Azure Key Vault as the JWK.KeyStore', LogLevel.ERROR);
throw new Error('The KEY_VAULT_CLIENT_SECRET variable must be set to use an Azure Key Vault as the JWK.KeyStore');
}
const clientSecret = envVars.KEY_VAULT_CLIENT_SECRET;
if(typeof envVars.KEY_VAULT_TENANT_ID === 'undefined' || envVars.KEY_VAULT_TENANT_ID.trim().length === 0) {
logMessage('The KEY_VAULT_TENANT_ID variable must be set to use an Azure Key Vault as the JWK.KeyStore', LogLevel.ERROR);
throw new Error('The KEY_VAULT_TENANT_ID variable must be set to use an Azure Key Vault as the JWK.KeyStore');
}
const tenantId = envVars.KEY_VAULT_TENANT_ID;
// Initialize the Azure Key Vault credentials object
const creds = new AzureKeyvaultCredentials(vaultName, clientId, clientSecret, tenantId);
// Return a new KeyvaultKeys object with the credentials specified
return new KeyvaultKeys(creds);
}
}

55
src/KeyvaultKeystore.ts Normal file
View file

@ -0,0 +1,55 @@
import { BaseKeystore, Keystore } from '@BridgemanAccessible/ba-auth_keystore';
import logMessage, { LogLevel } from '@BridgemanAccessible/ba-logging';
import { KeyvaultKeys } from './KeyvaultKeys.js';
/**
* Azure Key Vault implementation of the Keystore class
* That is, it uses the KeyVaultKeys class as the underlying KeyStore implementation
*/
export class KeyvaultKeystore extends BaseKeystore {
/** The type name of the keystore (used as the key for registration) */
public static readonly TYPE_NAME = 'azure';
private constructor(keyvaultKeys: KeyvaultKeys) {
super();
// We want to use our custom KeyStore implementation
this.keystore = keyvaultKeys;
}
static async init(
envVars: {
KEY_VAULT_NAME: string,
KEY_VAULT_CLIENT_ID: string,
KEY_VAULT_CLIENT_SECRET: string,
KEY_VAULT_TENANT_ID: string
} = {
KEY_VAULT_NAME: process.env.KEY_VAULT_NAME as string,
KEY_VAULT_CLIENT_ID: process.env.KEY_VAULT_CLIENT_ID as string,
KEY_VAULT_CLIENT_SECRET: process.env.KEY_VAULT_CLIENT_SECRET as string,
KEY_VAULT_TENANT_ID: process.env.KEY_VAULT_TENANT_ID as string
}
) {
// Create a new KeyvaultKeys object (Azure Key Vault specific implementation of the `JWK.KeyStore` interface)
// This is what actually does the work of managing the keys
const keyvaultKeystore = await KeyvaultKeys.init(envVars);
// Load any keys from the Azure Key Vault
// See `loadKeys` method documentation for more information of why this is necessary
const loadedKeys = await keyvaultKeystore.loadKeys();
// If no keys were loaded, setup some initial keys
if(loadedKeys.length === 0) {
logMessage('No keys found in the Hashicorp Vault, setting up initial keys...', LogLevel.DEBUG);
// Setup the initial keys
await keyvaultKeystore.setupInitialKeys();
}
return new KeyvaultKeystore(keyvaultKeystore);
}
}
Keystore.addKeystoreType(KeyvaultKeystore.TYPE_NAME, KeyvaultKeystore.init);

5
src/index.ts Normal file
View file

@ -0,0 +1,5 @@
import { KeyvaultKeystore } from './KeyvaultKeystore.js';
const TYPE_NAME = KeyvaultKeystore.TYPE_NAME;
export { KeyvaultKeystore, TYPE_NAME };

269
tests/KeyvaultKeys.test.ts Normal file
View file

@ -0,0 +1,269 @@
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import type * as NodeJose from 'node-jose';
import type { GetKeyOptions, CreateKeyOptions, BeginDeleteKeyOptions, KeyVaultKey, PollerLike, PollOperationState, DeletedKey } from '@azure/keyvault-keys';
// --- Setup Mocks ---
// Mock @azure/identity
jest.unstable_mockModule('@azure/identity', () => ({
ClientSecretCredential: jest.fn().mockImplementation(() => ({
getToken: jest.fn()
}))
}));
const sharedMockClient = {
getKey: jest.fn<(name: string, options?: GetKeyOptions | undefined) => Promise<Partial<KeyVaultKey>>>(),
createKey: jest.fn<(name: string, keyType: string, options?: CreateKeyOptions | undefined) => Promise<Partial<KeyVaultKey>>>(),
beginDeleteKey: jest.fn<(name: string, options?: BeginDeleteKeyOptions | undefined) => Promise<PollerLike<PollOperationState<DeletedKey>, DeletedKey>>>(),
listPropertiesOfKeys: jest.fn(),
};
// Mock @azure/keyvault-keys
jest.unstable_mockModule('@azure/keyvault-keys', () => ({
// Configure the constructor to return this SHARED object every time
KeyClient: jest.fn().mockReturnValue(sharedMockClient)
}));
// Mock Logging
jest.unstable_mockModule('@BridgemanAccessible/ba-logging', () => ({
default: jest.fn(), // logMessage default export
LogLevel: { DEBUG: 'debug', ERROR: 'error', WARN: 'warn' },
}));
// Mock BaseKeystore
jest.unstable_mockModule('@BridgemanAccessible/ba-auth_keystore', () => ({
BaseKeystore: class BaseKeystore { keystore: any; },
Keystore: { addKeystoreType: jest.fn() }
}));
// Mock node-forge (for fast key generation)
jest.unstable_mockModule('node-forge', () => ({
pki: {
rsa: {
generateKeyPair: jest.fn().mockImplementation((bits, callback: any) => {
// Return dummy key immediately
callback(null, {
publicKey: { n: 'modulus', e: 'exponent' },
privateKey: { d: 'privateExponent' }
});
})
}
}
}));
// --- Import Dependencies ---
// Dynamic imports required due to unstable_mockModule
const { AzureKeyvaultCredentials } = await import('../src/KeyvaultCredentials.js');
const { KeyvaultKeys } = await import('../src/KeyvaultKeys.js');
const { KeyvaultKeystore } = await import('../src/KeyvaultKeystore.js');
const nodeJose = (await import('node-jose')).default;
const { JWK } = nodeJose;
// --- Helper for Async Iterators ---
// This mocks the `for await (const x of client.listPropertiesOfKeys())` syntax
const mockAsyncIterator = (items: any[]) => {
return {
[Symbol.asyncIterator]: async function* () {
for (const item of items) {
yield item;
}
}
};
};
describe('Azure Key Vault Integration Tests', () => {
const envVars = {
KEY_VAULT_NAME: 'test-vault',
KEY_VAULT_CLIENT_ID: 'cid',
KEY_VAULT_CLIENT_SECRET: 'csec',
KEY_VAULT_TENANT_ID: 'tid'
};
beforeEach(() => {
jest.clearAllMocks();
// Reset defaults for the shared mock
sharedMockClient.listPropertiesOfKeys.mockReturnValue(mockAsyncIterator([]));
sharedMockClient.createKey.mockResolvedValue({});
sharedMockClient.beginDeleteKey.mockResolvedValue({} as any);
});
// --- Tests for Credentials ---
describe('AzureKeyvaultCredentials', () => {
it('should construct correct URL and credential objects', () => {
const creds = new AzureKeyvaultCredentials('my-vault', 'id', 'secret', 'tenant');
expect(creds.vaultUrl).toBe('https://my-vault.vault.azure.net');
expect(creds.getCredentials()).toEqual({
name: 'my-vault',
client_id: 'id',
client_secret: 'secret',
tenant_id: 'tenant'
});
});
});
// --- Tests for KeyvaultKeys ---
describe('KeyvaultKeys', () => {
describe('init', () => {
it('should throw if env vars are missing', async () => {
await expect(KeyvaultKeys.init({ ...envVars, KEY_VAULT_NAME: '' }))
.rejects.toThrow('The KEY_VAULT_NAME variable must be set');
});
});
describe('loadKeys', () => {
it('should list keys, fetch details, and hydrate local state', async () => {
// Mock List
sharedMockClient.listPropertiesOfKeys.mockReturnValue(
mockAsyncIterator([
{ name: 'key-1' }
])
);
// Mock GetKey (Must return Azure structure)
sharedMockClient.getKey.mockResolvedValue({
name: 'key-1',
key: {
kid: 'key-1',
kty: 'RSA',
n: new Uint8Array(new ArrayBuffer(1)),
e: new Uint8Array(new ArrayBuffer(1)),
keyOps: ['sign']
}
});
const kvKeys = await KeyvaultKeys.init(envVars);
await kvKeys.loadKeys();
// Verify interaction
expect(sharedMockClient.listPropertiesOfKeys).toHaveBeenCalled();
expect(sharedMockClient.getKey).toHaveBeenCalledWith('key-1');
// Verify Local State
const allKeys = kvKeys.all();
expect(allKeys.length).toBe(1);
expect(((allKeys as NodeJose.JWK.Key[])[0] as NodeJose.JWK.Key).kid).toBe('key-1');
expect(((allKeys as NodeJose.JWK.Key[])[0] as NodeJose.JWK.Key).alg).toBe('RS256'); // Verifies conversion logic ran
});
it('should handle empty vault gracefully', async () => {
sharedMockClient.listPropertiesOfKeys.mockReturnValue(mockAsyncIterator([]));
const kvKeys = await KeyvaultKeys.init(envVars);
const keys = await kvKeys.loadKeys();
expect(keys).toEqual([]);
expect(kvKeys.all()).toEqual([]);
});
});
describe('add', () => {
it('should write to Azure AND update local state', async () => {
const kvKeys = await KeyvaultKeys.init(envVars);
sharedMockClient.createKey.mockResolvedValue({}); // Return value doesn't matter much for this, but could
const rawKey = {
kty: 'oct',
k: '1234',
kid: 'new-key',
use: 'sig'
};
await kvKeys.add(rawKey as any);
// Check Azure Write
expect(sharedMockClient.createKey).toHaveBeenCalledWith(
'new-key',
'oct',
expect.objectContaining({ keyOps: ['sign', 'verify'] })
);
// Check Local State (THIS WILL FAIL ON CURRENT IMPLEMENTATION)
const storedKey = kvKeys.get('new-key');
expect(storedKey).toBeDefined();
expect(storedKey.kid).toBe('new-key');
});
});
describe('generate', () => {
it('should generate key via node-forge and save to Azure/Local', async () => {
const kvKeys = await KeyvaultKeys.init(envVars);
// Mock createKey to return success
sharedMockClient.createKey.mockResolvedValue({});
const generated = await kvKeys.generate('RSA', 2048, { use: 'sig' });
// Verify Azure call
expect(sharedMockClient.createKey).toHaveBeenCalled();
const args = sharedMockClient.createKey.mock.calls[0];
expect((args as [name: string, keyType: string, options?: CreateKeyOptions | undefined])[0]).toBe(generated.kid); // KID match
// Verify Local State
expect(kvKeys.get(generated.kid)).toBeDefined();
});
});
describe('remove', () => {
it('should delete from Azure and remove from local state', async () => {
const kvKeys = await KeyvaultKeys.init(envVars);
// Add a dummy key to local state
const dummyKey = await JWK.createKey('oct', 256, { kid: 'del-key' });
(kvKeys as any).keys.push(dummyKey);
// Mock delete promise
sharedMockClient.beginDeleteKey.mockResolvedValue({} as any);
kvKeys.remove(dummyKey);
// Verify Azure call
expect(sharedMockClient.beginDeleteKey).toHaveBeenCalledWith('del-key');
// Verify Local removal
expect(() => kvKeys.get('del-key')).toThrow();
});
});
});
// --- Tests for KeyvaultKeystore ---
describe('KeyvaultKeystore', () => {
it('should hydrate keys on init', async () => {
// Setup mocking for the static init sequence
sharedMockClient.listPropertiesOfKeys.mockReturnValue(mockAsyncIterator([{ name: 'existing-k' }]));
sharedMockClient.getKey.mockResolvedValue({
name: 'existing-k',
key: { kid: 'existing-k', kty: 'RSA' },
properties: {
name: 'existing-k',
vaultUrl: 'https://test-vault.vault.azure.net',
enabled: true
}
});
// We need to hijack the constructor internally used by init
// Since we mocked the module, the classes behave as defined in src,
// but we need to ensure the KeyClient instance used inside is our mock.
// The easiest way with `unstable_mockModule` in integration tests
// is relying on the fact that `new KeyClient()` returns our mock.
const keystore = await KeyvaultKeystore.init(envVars);
expect(sharedMockClient.listPropertiesOfKeys).toHaveBeenCalled();
expect(sharedMockClient.getKey).toHaveBeenCalledWith('existing-k');
});
it('should setup initial keys if vault is empty', async () => {
sharedMockClient.listPropertiesOfKeys.mockReturnValue(mockAsyncIterator([]));
sharedMockClient.createKey.mockResolvedValue({}); // Allow generate to succeed
await KeyvaultKeystore.init(envVars);
// Should trigger generate -> createKey twice (sig + enc)
expect(sharedMockClient.createKey).toHaveBeenCalledTimes(2);
});
});
});

20
tsconfig.build.json Normal file
View 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
View 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"
]
}

3630
yarn.lock Normal file

File diff suppressed because it is too large Load diff