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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@azure/app-configuration-provider",
"version": "2.5.1",
"version": "2.6.0",
"description": "The JavaScript configuration provider for Azure App Configuration",
"files": [
"dist/",
Expand Down
56 changes: 19 additions & 37 deletions src/appConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { AIConfigurationTracingOptions } from "./requestTracing/aiConfigurationT
import { KeyFilter, LabelFilter, SettingWatcher, SettingSelector, PagedSettingsWatcher, WatchedSetting } from "./types.js";
import { ConfigurationClientManager } from "./configurationClientManager.js";
import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js";
import { getStatusCode } from "./common/utils.js";
import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError, SnapshotReferenceError } from "./common/errors.js";
import { ErrorMessages } from "./common/errorMessages.js";

Expand Down Expand Up @@ -110,7 +111,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
#secretRefreshEnabled: boolean = false;
#secretReferences: ConfigurationSetting[] = []; // cached key vault references
#secretRefreshTimer: RefreshTimer | undefined = undefined;
#resolveSecretsInParallel: boolean = false;

/**
* Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors
Expand Down Expand Up @@ -202,7 +202,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
this.#secretRefreshEnabled = true;
this.#secretRefreshTimer = new RefreshTimer(secretRefreshIntervalInMs);
}
this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled ?? false;
}
this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions, this.#secretRefreshTimer));
this.#adapters.push(new JsonKeyValueAdapter());
Expand Down Expand Up @@ -558,22 +557,19 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
this.#aiConfigurationTracing.reset();
}

for (const adapter of this.#adapters) {
await adapter.preload?.(loadedSettings.filter(s => adapter.canProcess(s))); // dedup and warm the secret cache
}

for (const setting of loadedSettings) {
if (isSecretReference(setting)) {
this.#secretReferences.push(setting); // cache secret references for resolve/refresh secret separately
continue;
}
// adapt configuration settings to key-values
const [key, value] = await this.#processKeyValue(setting);
keyValues.push([key, value]);
}

if (this.#secretReferences.length > 0) {
await this.#resolveSecretReferences(this.#secretReferences, (key, value) => {
keyValues.push([key, value]);
});
}

this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion
for (const [k, v] of keyValues) {
this.#configMap.set(k, v); // reset the configuration
Expand Down Expand Up @@ -659,7 +655,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {

const watcher: SettingWatcher = this.#sentinels.get(watchedSetting)!; // watcher should always exist for sentinels
const isDeleted = response === undefined && watcher.etag !== undefined; // previously existed, now deleted
const isChanged = response && Number(response.statusCode) === 200 && watcher.etag !== response.etag; // etag changed
const isChanged = response && getStatusCode(response.statusCode) === 200 && watcher.etag !== response.etag; // etag changed
if (isDeleted || isChanged) {
changedSentinel = watchedSetting;
changedSentinelWatcher = { etag: isChanged ? response.etag : undefined };
Expand All @@ -671,7 +667,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {

if (needRefresh) {
for (const adapter of this.#adapters) {
await adapter.onChangeDetected();
await adapter.onChangeDetected?.();
}
await this.#loadSelectedKeyValues();

Expand Down Expand Up @@ -715,9 +711,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
return Promise.resolve(false);
}

await this.#resolveSecretReferences(this.#secretReferences, (key, value) => {
const keyVaultRefAdapter = this.#adapters.find(adapter => adapter instanceof AzureKeyVaultKeyValueAdapter) as AzureKeyVaultKeyValueAdapter | undefined;
if (keyVaultRefAdapter) {
// dedup and warm the secret cache
await keyVaultRefAdapter.preload(this.#secretReferences);
}

for (const setting of this.#secretReferences) {
const [key, value] = await this.#processKeyValue(setting);
this.#configMap.set(key, value);
});
}

this.#secretRefreshTimer.reset();
return Promise.resolve(true);
Expand Down Expand Up @@ -750,7 +753,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {

for await (const page of pageIterator) {
// when conditional request is sent, the response will be 304 if not changed
if (Number(page._response.status) === 200) { // created or changed
if (getStatusCode(page._response.status) === 200) { // created or changed
return true;
}
}
Expand Down Expand Up @@ -779,7 +782,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
try {
response = await this.#executeWithFailoverPolicy(funcToExecute);
} catch (error) {
if (isRestError(error) && Number(error.statusCode) === 404) {
if (isRestError(error) && getStatusCode(error.statusCode) === 404) {
response = undefined;
} else {
throw error;
Expand Down Expand Up @@ -822,7 +825,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
try {
response = await this.#executeWithFailoverPolicy(funcToExecute);
} catch (error) {
if (isRestError(error) && Number(error.statusCode) === 404) {
if (isRestError(error) && getStatusCode(error.statusCode) === 404) {
response = undefined;
} else {
throw error;
Expand Down Expand Up @@ -893,27 +896,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
throw new Error(ErrorMessages.ALL_FALLBACK_CLIENTS_FAILED);
}

async #resolveSecretReferences(secretReferences: ConfigurationSetting[], resultHandler: (key: string, value: unknown) => void): Promise<void> {
if (this.#resolveSecretsInParallel) {
const secretResolutionPromises: Promise<void>[] = [];
for (const setting of secretReferences) {
const secretResolutionPromise = this.#processKeyValue(setting)
.then(([key, value]) => {
resultHandler(key, value);
});
secretResolutionPromises.push(secretResolutionPromise);
}

// Wait for all secret resolution promises to be resolved
await Promise.all(secretResolutionPromises);
} else {
for (const setting of secretReferences) {
const [key, value] = await this.#processKeyValue(setting);
resultHandler(key, value);
}
}
}

async #processKeyValue(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
this.#setAIConfigurationTracing(setting);

Expand Down
12 changes: 12 additions & 0 deletions src/common/errorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { MIN_REFRESH_INTERVAL_IN_MS } from "../refresh/refreshOptions.js";
import { MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "../keyvault/keyVaultOptions.js";
import { ConfigurationSetting } from "@azure/app-configuration";

export const enum ErrorMessages {
INVALID_WATCHED_SETTINGS_KEY = "The characters '*' and ',' are not supported in key of watched settings.",
Expand All @@ -26,3 +27,14 @@ export const enum KeyVaultReferenceErrorMessages {
KEY_VAULT_OPTIONS_UNDEFINED = "Failed to process the Key Vault reference because Key Vault options are not configured.",
KEY_VAULT_REFERENCE_UNRESOLVABLE = "Failed to resolve the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."
}

export function buildKeyVaultReferenceErrorMessage(message: string, secretIdentifier?: string, setting?: ConfigurationSetting): string {
let errorMessage = message;
if (secretIdentifier) {
errorMessage += ` SecretIdentifier: '${secretIdentifier}'`;
}
if (setting) {
errorMessage += ` Key: '${setting.key}' Label: '${setting.label ?? ""}' ETag: '${setting.etag ?? ""}'`;
}
return errorMessage;
}
12 changes: 12 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,15 @@ export function shuffleList<T>(array: T[]): T[] {
export function instanceOfTokenCredential(obj: unknown) {
return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function";
}

/**
* Normalizes an HTTP status code to a number.
*
* The underlying App Configuration client may surface the status code either as a number
* or as a string (e.g. "200", "304", "404") depending on the runtime/transport. This helper
* coerces the value to a number so that status code comparisons behave consistently and
* refresh logic is not broken when the status is a string.
*/
export function getStatusCode(statusCode: number | string | undefined): number {
return Number(statusCode);
}
6 changes: 1 addition & 5 deletions src/jsonKeyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,7 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter {
return [setting.key, parsedValue];
}

async onChangeDetected(): Promise<void> {
return;
}

#tryParseJson(value: string): { success: true; result: unknown } | { success: false } {
#tryParseJson(value: string): { success: true; result: unknown } | { success: false } {
try {
return { success: true, result: JSON.parse(value) };
} catch (error) {
Expand Down
7 changes: 6 additions & 1 deletion src/keyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ export interface IKeyValueAdapter {
*/
processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>;

/**
* This method deduplicates and warms 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.
*/
onChangeDetected(): Promise<void>;
onChangeDetected?(): Promise<void>;
}
51 changes: 36 additions & 15 deletions src/keyvault/keyVaultKeyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import { AzureKeyVaultSecretProvider } from "./keyVaultSecretProvider.js";
import { KeyVaultOptions } from "./keyVaultOptions.js";
import { RefreshTimer } from "../refresh/refreshTimer.js";
import { ArgumentError, KeyVaultReferenceError } from "../common/errors.js";
import { KeyVaultReferenceErrorMessages } from "../common/errorMessages.js";
import { KeyVaultReferenceErrorMessages, buildKeyVaultReferenceErrorMessage } from "../common/errorMessages.js";
import { KeyVaultSecretIdentifier, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets";
import { isRestError } from "@azure/core-rest-pipeline";
import { AuthenticationError } from "@azure/identity";

export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
#keyVaultOptions: KeyVaultOptions | undefined;
Expand All @@ -35,17 +33,44 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
parseSecretReference(setting).value.secretId
);
} catch (error) {
throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", setting), { cause: error });
throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", undefined, setting), { cause: error });
}

try {
const secretValue = await this.#keyVaultSecretProvider.getSecretValue(secretIdentifier);
return [setting.key, secretValue];
} catch (error) {
if (isRestError(error) || error instanceof AuthenticationError) {
throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", setting, secretIdentifier.sourceId), { cause: error });
const secretValue = await this.#keyVaultSecretProvider.getSecretValue(secretIdentifier);
return [setting.key, secretValue];
}

async preload(settings: ConfigurationSetting[]): Promise<void> {
if (!this.#keyVaultOptions) {
return; // no-op when keyVaultOptions is not configured
}
// Deduplicate references by secret identifier (sourceId).
const uniqueSecrets = new Map<string, KeyVaultSecretIdentifier>();
for (const setting of settings) {
if (!this.canProcess(setting)) {
continue;
}
let secretIdentifier: KeyVaultSecretIdentifier;
try {
secretIdentifier = parseKeyVaultSecretIdentifier(
parseSecretReference(setting).value.secretId
);
} catch (error) {
throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", undefined, setting), { cause: error });
}
if (!uniqueSecrets.has(secretIdentifier.sourceId)) {
uniqueSecrets.set(secretIdentifier.sourceId, secretIdentifier);
}
}

// Resolve failures surface as KeyVaultReferenceError from the provider, identified by secret identifier.
const uniqueSecretIdentifiers = [...uniqueSecrets.values()];
if (this.#keyVaultOptions.parallelSecretResolutionEnabled) {
await Promise.all(uniqueSecretIdentifiers.map(secretIdentifier => this.#keyVaultSecretProvider.loadSecretValue(secretIdentifier)));
} else {
for (const secretIdentifier of uniqueSecretIdentifiers) {
await this.#keyVaultSecretProvider.loadSecretValue(secretIdentifier);
}
throw error;
}
}

Expand All @@ -54,7 +79,3 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
return;
}
}

function buildKeyVaultReferenceErrorMessage(message: string, setting: ConfigurationSetting, secretIdentifier?: string ): string {
return `${message} Key: '${setting.key}' Label: '${setting.label ?? ""}' ETag: '${setting.etag ?? ""}' ${secretIdentifier ? ` SecretIdentifier: '${secretIdentifier}'` : ""}`;
}
37 changes: 26 additions & 11 deletions src/keyvault/keyVaultSecretProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

import { KeyVaultOptions, MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyVaultOptions.js";
import { RefreshTimer } from "../refresh/refreshTimer.js";
import { ArgumentError } from "../common/errors.js";
import { ArgumentError, KeyVaultReferenceError } from "../common/errors.js";
import { SecretClient, KeyVaultSecretIdentifier } from "@azure/keyvault-secrets";
import { KeyVaultReferenceErrorMessages } from "../common/errorMessages.js";
import { KeyVaultReferenceErrorMessages, buildKeyVaultReferenceErrorMessage } from "../common/errorMessages.js";
import { isRestError } from "@azure/core-rest-pipeline";
import { AuthenticationError } from "@azure/identity";

export class AzureKeyVaultSecretProvider {
#keyVaultOptions: KeyVaultOptions | undefined;
Expand Down Expand Up @@ -33,21 +35,34 @@ export class AzureKeyVaultSecretProvider {
}
}

async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise<unknown> {
async loadSecretValue(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);
const shouldRefresh = this.#secretRefreshTimer?.canRefresh() ?? false;
if (this.#cachedSecretValues.has(identifierKey) && !shouldRefresh) {
return this.#cachedSecretValues.get(identifierKey); // already cached and still fresh
}
let secretValue: unknown;
try {
secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier);
} catch (error) {
if (isRestError(error) || error instanceof AuthenticationError) {
throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", secretIdentifier.sourceId), { cause: error });
}
throw error;
}

// Fallback to fetching the secret value from Key Vault.
const secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier);
this.#cachedSecretValues.set(identifierKey, secretValue);
return secretValue;
}

// Serves the secret value from cache when available; otherwise loads it from Key Vault on demand.
async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise<unknown> {
const identifierKey = secretIdentifier.sourceId;
if (this.#cachedSecretValues.has(identifierKey)) {
return this.#cachedSecretValues.get(identifierKey); // serve from cache
}
return this.loadSecretValue(secretIdentifier); // fallback: load on demand
}

clearCache(): void {
if (this.#minSecretRefreshTimer.canRefresh()) {
this.#cachedSecretValues.clear();
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

export const VERSION = "2.5.1";
export const VERSION = "2.6.0";
Loading
Loading