Initial Commmit
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 38s
All checks were successful
Publish to Private NPM Registry / publish (push) Successful in 38s
This commit is contained in:
commit
170b1a0cec
14 changed files with 4777 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.
|
||||||
8
README.md
Normal file
8
README.md
Normal 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
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,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
41
package.json
Normal file
41
package.json
Normal 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"
|
||||||
|
}
|
||||||
40
src/KeyvaultCredentials.ts
Normal file
40
src/KeyvaultCredentials.ts
Normal 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
477
src/KeyvaultKeys.ts
Normal 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
55
src/KeyvaultKeystore.ts
Normal 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
5
src/index.ts
Normal 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
269
tests/KeyvaultKeys.test.ts
Normal 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
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