From a2120967b19c987a692ce530e12af64d6e740bc4 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 12 Nov 2026 23:28:51 +0000 Subject: [PATCH] Share in-flight Azure token requests and run az CLI asynchronously Concurrent secret lookups now share one token request per resource, so the az CLI fallback runs once instead of once per secret. az runs via async spawn so it no longer blocks the event loop and times out other Key Vault requests. The CLI token cache is read from AZURE_CONFIG_DIR when set. Fixes #1275 --- .bumpy/azure-dedupe-token-requests.md | 5 ++ .../plugins/azure-key-vault/src/plugin.ts | 34 +++++++--- .../azure-key-vault/test/azure.test.ts | 66 +++++++++++++++++++ .../content/docs/plugins/azure-key-vault.mdx | 2 +- 4 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 .bumpy/azure-dedupe-token-requests.md diff --git a/.bumpy/azure-dedupe-token-requests.md b/.bumpy/azure-dedupe-token-requests.md new file mode 100644 index 000000000..42a23010f --- /dev/null +++ b/.bumpy/azure-dedupe-token-requests.md @@ -0,0 +1,5 @@ +--- +"@varlock/azure-key-vault-plugin": patch +--- + +Fetch the Azure access token once per service when resolving many secrets at once, run `az account get-access-token` without blocking other requests, and read the Azure CLI token cache from `AZURE_CONFIG_DIR` when set diff --git a/packages/plugins/azure-key-vault/src/plugin.ts b/packages/plugins/azure-key-vault/src/plugin.ts index 0bfe1d64a..b79b499ce 100644 --- a/packages/plugins/azure-key-vault/src/plugin.ts +++ b/packages/plugins/azure-key-vault/src/plugin.ts @@ -7,7 +7,7 @@ import { createHash, createHmac } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { execSync } from 'node:child_process'; +import { spawnAsync } from '@env-spec/utils/exec-helpers'; import { getOidcToken } from '@env-spec/utils/oidc-tokens'; const { ValidationError, SchemaError, ResolutionError } = plugin.ERRORS; @@ -231,6 +231,8 @@ class AzurePluginInstance { private oidcToken?: string; /** cached Entra access tokens, keyed by resource (Key Vault and App Configuration use different scopes) */ private cachedTokens = new Map(); + /** token requests in flight, keyed by resource, so concurrent lookups share one request */ + private inflightTokens = new Map>(); private fetchCache = new Map>(); /** optional cache TTL - when set, resolved values are cached */ cacheTtl?: string | number; @@ -355,10 +357,12 @@ class AzurePluginInstance { private async getAzureCliToken(resource: string): Promise { const scope = `${resource}/.default`; + // az stores its config and token caches in AZURE_CONFIG_DIR when set, ~/.azure otherwise + const azureConfigDir = process.env.AZURE_CONFIG_DIR || join(homedir(), '.azure'); // Try the older accessTokens.json format first try { - const tokenCachePath = join(homedir(), '.azure', 'accessTokens.json'); + const tokenCachePath = join(azureConfigDir, 'accessTokens.json'); const tokenCacheContent = await readFile(tokenCachePath, 'utf-8'); const tokens = JSON.parse(tokenCacheContent); @@ -387,7 +391,7 @@ class AzurePluginInstance { // Try the newer MSAL token cache format try { - const msalCachePath = join(homedir(), '.azure', 'msal_token_cache.json'); + const msalCachePath = join(azureConfigDir, 'msal_token_cache.json'); const msalCacheContent = await readFile(msalCachePath, 'utf-8'); const msalCache = JSON.parse(msalCacheContent); @@ -421,11 +425,14 @@ class AzurePluginInstance { try { debug('No cached token found, attempting to get token from az CLI directly'); // Scope the request to the configured tenant so we don't get a token for the wrong account. - const tenantArg = this.tenantId ? ` --tenant ${this.tenantId}` : ''; - const result = execSync(`az account get-access-token --resource ${resource}${tenantArg}`, { - encoding: 'utf-8', + const args = ['account', 'get-access-token', '--resource', resource]; + if (this.tenantId) args.push('--tenant', this.tenantId); + // async so a slow CLI does not block the event loop (and time out other in-flight requests) + const result = await spawnAsync('az', args, { timeout: 10000, - stdio: ['ignore', 'pipe', 'ignore'], // Suppress stderr + stdio: ['ignore', 'pipe', 'pipe'], + // az is a .cmd shim on Windows, which can only be run through a shell + shell: process.platform === 'win32', }); const tokenData = JSON.parse(result); @@ -524,8 +531,19 @@ class AzurePluginInstance { /** * Get an Entra access token for the given resource (e.g. Key Vault or App Configuration). * Tokens are cached per resource since each service requires its own scope. + * Concurrent calls for the same resource share one in-flight request; the entry is + * cleared once it settles so later calls re-check expiry and failures can be retried. */ - private async getAccessToken(resource: string): Promise { + private getAccessToken(resource: string): Promise { + let inflight = this.inflightTokens.get(resource); + if (!inflight) { + inflight = this.fetchAccessToken(resource).finally(() => this.inflightTokens.delete(resource)); + this.inflightTokens.set(resource, inflight); + } + return inflight; + } + + private async fetchAccessToken(resource: string): Promise { // Check if we have a cached token that's still valid (with 5 min buffer) const cachedToken = this.cachedTokens.get(resource); if (cachedToken && cachedToken.expiresAt > Date.now() + 5 * 60 * 1000) { diff --git a/packages/plugins/azure-key-vault/test/azure.test.ts b/packages/plugins/azure-key-vault/test/azure.test.ts index a641da81f..afb03ec03 100644 --- a/packages/plugins/azure-key-vault/test/azure.test.ts +++ b/packages/plugins/azure-key-vault/test/azure.test.ts @@ -1,6 +1,8 @@ import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { createHash, createHmac } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -665,6 +667,70 @@ describe('azureSecret()', () => { expect(fake.requests.some((r) => r.pathname === '/secrets/database-url')).toBe(true); }); + test('shares one token request across secrets resolved concurrently', async () => { + const fake = await startFakeAzure({ vaultSecrets: { 'secret-a': 'value-a', 'secret-b': 'value-b', 'secret-c': 'value-c' } }); + + await pluginTest({ + injectValues: spInjectValues, + schema: outdent` + ${servicePrincipalSchema(fake, `,\n# vaultUrl="${fake.url}/"`)} + # @sensitive + SECRET_A=azureSecret("secret-a") + # @sensitive + SECRET_B=azureSecret("secret-b") + # @sensitive + SECRET_C=azureSecret("secret-c") + `, + expectValues: { SECRET_A: 'value-a', SECRET_B: 'value-b', SECRET_C: 'value-c' }, + })(); + + expect(fake.requests.filter((r) => r.pathname.endsWith('/oauth2/v2.0/token'))).toHaveLength(1); + }); + + test.skipIf(process.platform === 'win32')('falls back to one async az CLI call when no cached CLI token is found', async () => { + const fake = await startFakeAzure({ vaultSecrets: { 'secret-a': 'value-a', 'secret-b': 'value-b' } }); + + // a fake `az` on PATH that logs each call and prints a token, plus an empty AZURE_CONFIG_DIR + // so the plugin finds no token cache files and has to shell out + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-az-test-')); + const callLog = path.join(tmpDir, 'calls.log'); + const azPath = path.join(tmpDir, 'az'); + fs.writeFileSync(azPath, outdent` + #!/bin/sh + echo "$@" >> "${callLog}" + echo '{"accessToken":"${ENTRA_TOKEN}","expiresOn":"2099-01-01 00:00:00.000000"}' + `, { mode: 0o755 }); + const configDir = path.join(tmpDir, 'azure-config'); + fs.mkdirSync(configDir); + + const origPath = process.env.PATH; + const origConfigDir = process.env.AZURE_CONFIG_DIR; + process.env.PATH = `${tmpDir}${path.delimiter}${origPath}`; + process.env.AZURE_CONFIG_DIR = configDir; + try { + await pluginTest({ + schema: outdent` + # @plugin(${PLUGIN_PATH}) + # @initAzure(vaultUrl="${fake.url}/") + # --- + # @sensitive + SECRET_A=azureSecret("secret-a") + # @sensitive + SECRET_B=azureSecret("secret-b") + `, + expectValues: { SECRET_A: 'value-a', SECRET_B: 'value-b' }, + })(); + } finally { + process.env.PATH = origPath; + if (origConfigDir === undefined) delete process.env.AZURE_CONFIG_DIR; + else process.env.AZURE_CONFIG_DIR = origConfigDir; + } + + const calls = fs.readFileSync(callLog, 'utf-8').trim().split('\n'); + fs.rmSync(tmpDir, { recursive: true, force: true }); + expect(calls).toEqual(['account get-access-token --resource https://vault.azure.net']); + }, 15_000); + test('errors when the instance has no vaultUrl configured', async () => { const fake = await startFakeAzure({ settings: [{ key: 'X', value: 'y' }] }); diff --git a/packages/varlock-website/src/content/docs/plugins/azure-key-vault.mdx b/packages/varlock-website/src/content/docs/plugins/azure-key-vault.mdx index 55dc7c137..4cb818fec 100644 --- a/packages/varlock-website/src/content/docs/plugins/azure-key-vault.mdx +++ b/packages/varlock-website/src/content/docs/plugins/azure-key-vault.mdx @@ -60,7 +60,7 @@ The plugin tries authentication methods in this priority order: 1. **Service Principal** - If all three credentials (`tenantId`, `clientId`, `clientSecret`) are provided 2. **OIDC federated credentials** - If `tenantId` and `clientId` are provided without `clientSecret`, uses [OIDC workload identity federation](/guides/oidc/) 3. **Managed Identity** - Automatically used when running on Azure infrastructure -4. **Azure CLI** - Falls back to `az login` for local development +4. **Azure CLI** - Falls back to `az login` for local development. Reads the CLI token cache (from `AZURE_CONFIG_DIR` if set, otherwise `~/.azure`), and runs `az account get-access-token` if no valid cached token is found The same chain is used for both services. Only the token scope differs (`https://vault.azure.net` for Key Vault, `https://azconfig.io` for App Configuration), and tokens are cached per scope. App Configuration additionally accepts an access-key connection string via `appConfigConnectionString`, which bypasses this chain for App Configuration requests only (see [Access-key connection string](#access-key-connection-string) below).