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