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