Skip to content
Open
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
6 changes: 6 additions & 0 deletions src/appConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}

if (this.#secretReferences.length > 0) {
for (const adapter of this.#adapters) {
await adapter.preload?.(this.#secretReferences); // dedup and warm the secret cache
}
await this.#resolveSecretReferences(this.#secretReferences, (key, value) => {
keyValues.push([key, value]);
});
Expand Down Expand Up @@ -715,6 +718,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
return Promise.resolve(false);
}

for (const adapter of this.#adapters) {
await adapter.preload?.(this.#secretReferences); // dedup and warm the secret cache
}
await this.#resolveSecretReferences(this.#secretReferences, (key, value) => {
this.#configMap.set(key, value);
});
Expand Down
6 changes: 6 additions & 0 deletions src/keyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export interface IKeyValueAdapter {
*/
processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>;

/**
* Optionally batch-resolves settings ahead of processKeyValue, e.g. to deduplicate and warm up
* Key Vault secret requests so that processKeyValue only reads from cache.
*/
preload?(settings: ConfigurationSetting[]): Promise<void>;

/**
* This method is called when a change is detected in the configuration setting.
*/
Expand Down
26 changes: 26 additions & 0 deletions src/keyvault/keyVaultKeyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,32 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
}
}

/**
* Deduplicates secret references by their normalized secret identifier (sourceId) and preloads each
* unique secret exactly once, warming the cache so that processKeyValue only reads from it.
* Best-effort: unparseable references are skipped and re-surfaced by processKeyValue with full context.
*/
async preload(settings: ConfigurationSetting[]): Promise<void> {
if (!this.#keyVaultOptions) {
return; // nothing to do; processKeyValue will throw the proper ArgumentError
}
const uniqueSecretIdentifiers = new Map<string, KeyVaultSecretIdentifier>();
for (const setting of settings) {
if (!this.canProcess(setting)) {
continue;
}
try {
const secretIdentifier = parseKeyVaultSecretIdentifier(
parseSecretReference(setting).value.secretId
);
uniqueSecretIdentifiers.set(secretIdentifier.sourceId, secretIdentifier); // dedup by sourceId
} catch {
// Skip invalid references; processKeyValue re-parses and raises KeyVaultReferenceError with context.
}
}
await this.#keyVaultSecretProvider.preloadSecrets([...uniqueSecretIdentifiers.values()]);
}

async onChangeDetected(): Promise<void> {
this.#keyVaultSecretProvider.clearCache();
return;
Expand Down
47 changes: 42 additions & 5 deletions src/keyvault/keyVaultSecretProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,53 @@ export class AzureKeyVaultSecretProvider {
}
}

/**
* Fetches the given unique secrets ahead of resolution to warm the cache. Honors the secret refresh
* timer and the parallel resolution option. This is best-effort: per-secret failures are swallowed so
* that the error is surfaced with full context later by getSecretValue during resolution.
*/
async preloadSecrets(secretIdentifiers: KeyVaultSecretIdentifier[]): Promise<void> {
const loadSecret = async (secretIdentifier: KeyVaultSecretIdentifier) => {
try {
await this.#loadSecretValue(secretIdentifier);
} catch {
// Leave uncached; getSecretValue re-fetches and surfaces the error during resolution.
}
};

if (this.#keyVaultOptions?.parallelSecretResolutionEnabled) {
await Promise.all(secretIdentifiers.map(loadSecret));
} else {
for (const secretIdentifier of secretIdentifiers) {
await loadSecret(secretIdentifier);
}
}
}

/**
* Fetches a secret value into the cache if it is not cached yet, or if the secret refresh interval has
* expired. This is the only place the refresh timer gates a fetch.
*/
async #loadSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise<void> {
const identifierKey = secretIdentifier.sourceId;
const shouldRefresh = this.#secretRefreshTimer?.canRefresh() ?? false;
if (this.#cachedSecretValues.has(identifierKey) && !shouldRefresh) {
return; // already cached and still fresh
}
this.#cachedSecretValues.set(identifierKey, await this.#getSecretValueFromKeyVault(secretIdentifier));
}

async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise<unknown> {
const identifierKey = secretIdentifier.sourceId;

// If the refresh interval is not expired, return the cached value if available.
if (this.#cachedSecretValues.has(identifierKey) &&
(!this.#secretRefreshTimer || !this.#secretRefreshTimer.canRefresh())) {
return this.#cachedSecretValues.get(identifierKey);
// Return the cached value if available. Freshness is handled by preloadSecrets, which warms the
// cache before resolution.
if (this.#cachedSecretValues.has(identifierKey)) {
return this.#cachedSecretValues.get(identifierKey);
}

// Fallback to fetching the secret value from Key Vault.
// Fallback for secrets that preload skipped or failed to fetch. Failures are not cached, so a
// subsequent call will retry.
const secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier);
this.#cachedSecretValues.set(identifierKey, secretValue);
return secretValue;
Expand Down
213 changes: 213 additions & 0 deletions test/keyvault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,219 @@ describe("key vault reference", function () {
});
});

describe("key vault reference deduplication", function () {
afterEach(() => {
restoreMocks();
});

// 5 settings all referencing the same secret URI (same sourceId).
const sameSecretUri = "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName";
function mockDuplicateReferences() {
const kvs = ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]
.map((key) => createMockedKeyVaultReference(key, sameSecretUri));
mockAppConfigurationClientListConfigurationSettings([kvs]);
}

it("should resolve duplicate references with a single Key Vault request in parallel mode", async () => {
mockDuplicateReferences();
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
const stub = sinon.stub(client, "getSecret").callsFake(async () => {
// Introduce a delay so that all references start before the first one resolves.
await sleepInMs(100);
return { value: "SecretValue" } as KeyVaultSecret;
});

const settings = await load(createMockedConnectionString(), {
keyVaultOptions: {
secretClients: [client],
parallelSecretResolutionEnabled: true
}
});

expect(stub.callCount).eq(1);
for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) {
expect(settings.get(key)).eq("SecretValue");
}
});

it("should resolve duplicate references with a single Key Vault request in sequential mode", async () => {
mockDuplicateReferences();
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
const stub = sinon.stub(client, "getSecret").callsFake(async () => {
return { value: "SecretValue" } as KeyVaultSecret;
});

const settings = await load(createMockedConnectionString(), {
keyVaultOptions: {
secretClients: [client]
}
});

expect(stub.callCount).eq(1);
for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) {
expect(settings.get(key)).eq("SecretValue");
}
});

it("should invoke secret resolver only once for duplicate references", async () => {
mockDuplicateReferences();
const resolver = sinon.stub().callsFake(async () => {
await sleepInMs(100);
return "ResolvedSecretValue";
});

const settings = await load(createMockedConnectionString(), {
keyVaultOptions: {
secretResolver: resolver,
parallelSecretResolutionEnabled: true
}
});

expect(resolver.callCount).eq(1);
for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) {
expect(settings.get(key)).eq("ResolvedSecretValue");
}
});

it("should fetch different versions of the same secret independently", async () => {
const versionedUri = "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName/741a0fc52610449baffd6e1c55b9d459";
const kvs = [
createMockedKeyVaultReference("TestKey", sameSecretUri),
createMockedKeyVaultReference("TestKeyVersioned", versionedUri)
];
mockAppConfigurationClientListConfigurationSettings([kvs]);
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
const stub = sinon.stub(client, "getSecret").callsFake(async (_name, options) => {
await sleepInMs(100);
return { value: options?.version ? "VersionedValue" : "LatestValue" } as KeyVaultSecret;
});

const settings = await load(createMockedConnectionString(), {
keyVaultOptions: {
secretClients: [client],
parallelSecretResolutionEnabled: true
}
});

expect(stub.callCount).eq(2);
expect(settings.get("TestKey")).eq("LatestValue");
expect(settings.get("TestKeyVersioned")).eq("VersionedValue");
});

it("should recover and not cache the failure when preload fails to fetch a secret", async () => {
mockDuplicateReferences();
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
const stub = sinon.stub(client, "getSecret");
// The preload fetch (best-effort) rejects and must not be cached; the on-demand resolution then
// recovers by re-fetching successfully.
stub.onCall(0).callsFake(async () => {
await sleepInMs(100);
throw new Error("Key Vault unavailable");
});
stub.callsFake(async () => {
return { value: "SecretValue" } as KeyVaultSecret;
});

const settings = await load(createMockedConnectionString(), {
keyVaultOptions: {
secretClients: [client],
parallelSecretResolutionEnabled: true
}
});

// The failed preload is not cached, so all references still resolve successfully.
for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) {
expect(settings.get(key)).eq("SecretValue");
}
});

it("should re-fetch once per unique secret on a key-value change reload", async () => {
const sentinelEtag = "sentinel-etag";
const kvs = [
...["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"].map((key) => createMockedKeyVaultReference(key, sameSecretUri)),
createMockedKeyValue({ key: "sentinel", value: "v1", etag: sentinelEtag })
];
mockAppConfigurationClientListConfigurationSettings([kvs]);
mockAppConfigurationClientGetConfigurationSetting(kvs);

const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
let callCount = 0;
sinon.stub(client, "getSecret").callsFake(async () => {
callCount++;
await sleepInMs(100);
return { value: `SecretValue-${callCount}` } as KeyVaultSecret;
});

const settings = await load(createMockedConnectionString(), {
refreshOptions: {
enabled: true,
refreshIntervalInMs: 1000,
watchedSettings: [{ key: "sentinel" }]
},
keyVaultOptions: {
secretClients: [client],
parallelSecretResolutionEnabled: true
}
});
// Initial load resolves the duplicates with a single request.
expect(callCount).eq(1);

// Wait past the min secret refresh interval so the key-value change reload re-fetches secrets.
await sleepInMs(60_000 + 100);

// Trigger a key-value change reload by changing the sentinel.
const updatedKvs = [
...["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"].map((key) => createMockedKeyVaultReference(key, sameSecretUri)),
createMockedKeyValue({ key: "sentinel", value: "v2", etag: "sentinel-etag-2" })
];
restoreMocks();
mockAppConfigurationClientListConfigurationSettings([updatedKvs]);
mockAppConfigurationClientGetConfigurationSetting(updatedKvs);
let reloadCallCount = 0;
sinon.stub(client, "getSecret").callsFake(async () => {
reloadCallCount++;
await sleepInMs(100);
return { value: "SecretValue-reloaded" } as KeyVaultSecret;
});

await settings.refresh();

// The key-value change reload clears the cache and re-fetches, but only once for the unique secret.
expect(reloadCallCount).eq(1);
for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) {
expect(settings.get(key)).eq("SecretValue-reloaded");
}
});

it("should re-fetch once per unique secret on each refresh round", async () => {
mockDuplicateReferences();
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
let callCount = 0;
sinon.stub(client, "getSecret").callsFake(async () => {
callCount++;
await sleepInMs(100);
return { value: `SecretValue-${callCount}` } as KeyVaultSecret;
});

const settings = await load(createMockedConnectionString(), {
keyVaultOptions: {
secretClients: [client],
secretRefreshIntervalInMs: 60_000,
parallelSecretResolutionEnabled: true
}
});
// Initial load resolves duplicates with a single request.
expect(callCount).eq(1);
expect(settings.get("TestKey1")).eq("SecretValue-1");

// After the secret refresh interval elapses, the refresh round re-fetches once.
await sleepInMs(60_000 + 100);
await settings.refresh();
Comment on lines +351 to +352
Comment on lines +350 to +352
expect(callCount).eq(2);
expect(settings.get("TestKey1")).eq("SecretValue-2");
});
});

describe("key vault secret refresh", function () {

beforeEach(() => {
Expand Down
Loading