Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/azure-dedupe-token-requests.md
Original file line number Diff line number Diff line change
@@ -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
34 changes: 26 additions & 8 deletions packages/plugins/azure-key-vault/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, CachedToken>();
/** token requests in flight, keyed by resource, so concurrent lookups share one request */
private inflightTokens = new Map<string, Promise<string>>();
private fetchCache = new Map<string, Promise<string>>();
/** optional cache TTL - when set, resolved values are cached */
cacheTtl?: string | number;
Expand Down Expand Up @@ -355,10 +357,12 @@ class AzurePluginInstance {

private async getAzureCliToken(resource: string): Promise<string | undefined> {
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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string> {
private getAccessToken(resource: string): Promise<string> {
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<string> {
// 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) {
Expand Down
66 changes: 66 additions & 0 deletions packages/plugins/azure-key-vault/test/azure.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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' }] });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Loading