diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 48077410..693cec90 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -22,6 +22,7 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js"; +import { MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyvault/KeyVaultOptions.js"; import { Disposable } from "./common/disposable.js"; import { FEATURE_FLAGS_KEY_NAME, @@ -91,16 +92,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Aka watched settings. */ + #refreshEnabled: boolean = false; #sentinels: ConfigurationSettingId[] = []; #watchAll: boolean = false; #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #kvRefreshTimer: RefreshTimer; // Feature flags + #featureFlagEnabled: boolean = false; + #featureFlagRefreshEnabled: boolean = false; #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #ffRefreshTimer: RefreshTimer; // Key Vault references + #secretRefreshEnabled: boolean = false; + #secretReferences: ConfigurationSetting[] = []; // cached key vault references + #secretRefreshTimer: RefreshTimer; #resolveSecretsInParallel: boolean = false; /** @@ -129,14 +136,15 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#featureFlagTracing = new FeatureFlagTracingOptions(); } - if (options?.trimKeyPrefixes) { + if (options?.trimKeyPrefixes !== undefined) { this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); } // if no selector is specified, always load key values using the default selector: key="*" and label="\0" this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); - if (options?.refreshOptions?.enabled) { + if (options?.refreshOptions?.enabled === true) { + this.#refreshEnabled = true; const { refreshIntervalInMs, watchedSettings } = options.refreshOptions; if (watchedSettings === undefined || watchedSettings.length === 0) { this.#watchAll = true; // if no watched settings is specified, then watch all @@ -156,53 +164,48 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); - } else { - this.#kvRefreshInterval = refreshIntervalInMs; } + this.#kvRefreshInterval = refreshIntervalInMs; } this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval); } // feature flag options - if (options?.featureFlagOptions?.enabled) { + if (options?.featureFlagOptions?.enabled === true) { + this.#featureFlagEnabled = true; // validate feature flag selectors, only load feature flags when enabled this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); - if (options.featureFlagOptions.refresh?.enabled) { + if (options.featureFlagOptions.refresh?.enabled === true) { + this.#featureFlagRefreshEnabled = true; const { refreshIntervalInMs } = options.featureFlagOptions.refresh; // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); - } else { - this.#ffRefreshInterval = refreshIntervalInMs; } + this.#ffRefreshInterval = refreshIntervalInMs; } this.#ffRefreshTimer = new RefreshTimer(this.#ffRefreshInterval); } } - if (options?.keyVaultOptions?.parallelSecretResolutionEnabled) { - this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled; + if (options?.keyVaultOptions !== undefined) { + const { secretRefreshIntervalInMs } = options.keyVaultOptions; + if (secretRefreshIntervalInMs !== undefined) { + if (secretRefreshIntervalInMs < MIN_SECRET_REFRESH_INTERVAL_IN_MS) { + throw new RangeError(`The Key Vault secret refresh interval cannot be less than ${MIN_SECRET_REFRESH_INTERVAL_IN_MS} milliseconds.`); + } + this.#secretRefreshEnabled = true; + this.#secretRefreshTimer = new RefreshTimer(secretRefreshIntervalInMs); + } + this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled ?? false; } - - this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); + this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions, this.#secretRefreshTimer)); this.#adapters.push(new JsonKeyValueAdapter()); } - get #refreshEnabled(): boolean { - return !!this.#options?.refreshOptions?.enabled; - } - - get #featureFlagEnabled(): boolean { - return !!this.#options?.featureFlagOptions?.enabled; - } - - get #featureFlagRefreshEnabled(): boolean { - return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled; - } - get #requestTraceOptions(): RequestTracingOptions { return { enabled: this.#requestTracingEnabled, @@ -337,8 +340,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Refreshes the configuration. */ async refresh(): Promise { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled && !this.#secretRefreshEnabled) { + throw new InvalidOperationError("Refresh is not enabled for key-values, feature flags or Key Vault secrets."); } if (this.#refreshInProgress) { @@ -356,8 +359,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Registers a callback function to be called when the configuration is refreshed. */ onRefresh(listener: () => any, thisArg?: any): Disposable { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled && !this.#secretRefreshEnabled) { + throw new InvalidOperationError("Refresh is not enabled for key-values, feature flags or Key Vault secrets."); } const boundedListener = listener.bind(thisArg); @@ -425,8 +428,20 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #refreshTasks(): Promise { const refreshTasks: Promise[] = []; - if (this.#refreshEnabled) { - refreshTasks.push(this.#refreshKeyValues()); + if (this.#refreshEnabled || this.#secretRefreshEnabled) { + refreshTasks.push( + this.#refreshKeyValues() + .then(keyValueRefreshed => { + // Only refresh secrets if key values didn't change and secret refresh is enabled + // If key values are refreshed, all secret references will be refreshed as well. + if (!keyValueRefreshed && this.#secretRefreshEnabled) { + // Returns the refreshSecrets promise directly. + // in a Promise chain, this automatically flattens nested Promises without requiring await. + return this.#refreshSecrets(); + } + return keyValueRefreshed; + }) + ); } if (this.#featureFlagRefreshEnabled) { refreshTasks.push(this.#refreshFeatureFlags()); @@ -530,6 +545,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Loads selected key-values and watched settings (sentinels) for refresh from App Configuration to the local configuration. */ async #loadSelectedAndWatchedKeyValues() { + this.#secretReferences = []; // clear all cached key vault reference configuration settings const keyValues: [key: string, value: unknown][] = []; const loadedSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings(); if (this.#refreshEnabled && !this.#watchAll) { @@ -537,28 +553,24 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { - // Reset old AI configuration tracing in order to track the information present in the current response from server. + // reset old AI configuration tracing in order to track the information present in the current response from server this.#aiConfigurationTracing.reset(); } - const secretResolutionPromises: Promise[] = []; for (const setting of loadedSettings) { - if (this.#resolveSecretsInParallel && isSecretReference(setting)) { - // secret references are resolved asynchronously to improve performance - const secretResolutionPromise = this.#processKeyValue(setting) - .then(([key, value]) => { - keyValues.push([key, value]); - }); - secretResolutionPromises.push(secretResolutionPromise); + 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 (secretResolutionPromises.length > 0) { - // wait for all secret resolution promises to be resolved - await Promise.all(secretResolutionPromises); + + 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 @@ -626,7 +638,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async #refreshKeyValues(): Promise { // if still within refresh interval/backoff, return - if (!this.#kvRefreshTimer.canRefresh()) { + if (this.#kvRefreshTimer === undefined || !this.#kvRefreshTimer.canRefresh()) { return Promise.resolve(false); } @@ -650,6 +662,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (needRefresh) { + for (const adapter of this.#adapters) { + await adapter.onChangeDetected(); + } await this.#loadSelectedAndWatchedKeyValues(); } @@ -663,7 +678,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async #refreshFeatureFlags(): Promise { // if still within refresh interval/backoff, return - if (!this.#ffRefreshTimer.canRefresh()) { + if (this.#ffRefreshInterval === undefined || !this.#ffRefreshTimer.canRefresh()) { return Promise.resolve(false); } @@ -676,6 +691,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(needRefresh); } + async #refreshSecrets(): Promise { + // if still within refresh interval/backoff, return + if (this.#secretRefreshTimer === undefined || !this.#secretRefreshTimer.canRefresh()) { + return Promise.resolve(false); + } + + // if no cached key vault references, return + if (this.#secretReferences.length === 0) { + return Promise.resolve(false); + } + + await this.#resolveSecretReferences(this.#secretReferences, (key, value) => { + this.#configMap.set(key, value); + }); + + this.#secretRefreshTimer.reset(); + return Promise.resolve(true); + } + /** * Checks whether the key-value collection has changed. * @param selectors - The @see PagedSettingSelector of the kev-value collection. @@ -804,6 +838,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { throw new Error("All fallback clients failed to get configuration settings."); } + async #resolveSecretReferences(secretReferences: ConfigurationSetting[], resultHandler: (key: string, value: unknown) => void): Promise { + if (this.#resolveSecretsInParallel) { + const secretResolutionPromises: Promise[] = []; + 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): Promise<[string, unknown]> { this.#setAIConfigurationTracing(setting); diff --git a/src/ConfigurationClientManager.ts b/src/ConfigurationClientManager.ts index 72a3bfeb..9fc2331b 100644 --- a/src/ConfigurationClientManager.ts +++ b/src/ConfigurationClientManager.ts @@ -12,7 +12,7 @@ import { ArgumentError } from "./common/error.js"; // Configuration client retry options const CLIENT_MAX_RETRIES = 2; -const CLIENT_MAX_RETRY_DELAY = 60_000; // 1 minute in milliseconds +const CLIENT_MAX_RETRY_DELAY_IN_MS = 60_000; const TCP_ORIGIN_KEY_NAME = "_origin._tcp"; const ALT_KEY_NAME = "_alt"; @@ -21,9 +21,9 @@ const ENDPOINT_KEY_NAME = "Endpoint"; const ID_KEY_NAME = "Id"; const SECRET_KEY_NAME = "Secret"; const TRUSTED_DOMAIN_LABELS = [".azconfig.", ".appconfig."]; -const FALLBACK_CLIENT_EXPIRE_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds -const MINIMAL_CLIENT_REFRESH_INTERVAL = 30_000; // 30 seconds in milliseconds -const DNS_RESOLVER_TIMEOUT = 3_000; // 3 seconds in milliseconds, in most cases, dns resolution should be within 200 milliseconds +const FALLBACK_CLIENT_EXPIRE_INTERVAL_IN_MS = 60 * 60 * 1000; +const MINIMAL_CLIENT_REFRESH_INTERVAL_IN_MS = 30_000; +const DNS_RESOLVER_TIMEOUT_IN_MS = 3_000; const DNS_RESOLVER_TRIES = 2; const MAX_ALTNATIVE_SRV_COUNT = 10; @@ -120,11 +120,11 @@ export class ConfigurationClientManager { const currentTime = Date.now(); // Filter static clients whose backoff time has ended let availableClients = this.#staticClients.filter(client => client.backoffEndTime <= currentTime); - if (currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL && + if (currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL_IN_MS && (!this.#dynamicClients || // All dynamic clients are in backoff means no client is available this.#dynamicClients.every(client => currentTime < client.backoffEndTime) || - currentTime >= this.#lastFallbackClientUpdateTime + FALLBACK_CLIENT_EXPIRE_INTERVAL)) { + currentTime >= this.#lastFallbackClientUpdateTime + FALLBACK_CLIENT_EXPIRE_INTERVAL_IN_MS)) { await this.#discoverFallbackClients(this.endpoint.hostname); return availableClients.concat(this.#dynamicClients); } @@ -142,7 +142,7 @@ export class ConfigurationClientManager { async refreshClients() { const currentTime = Date.now(); if (this.#isFailoverable && - currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL) { + currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL_IN_MS) { await this.#discoverFallbackClients(this.endpoint.hostname); } } @@ -185,7 +185,7 @@ export class ConfigurationClientManager { try { // https://nodejs.org/api/dns.html#dnspromisesresolvesrvhostname - const resolver = new this.#dns.Resolver({timeout: DNS_RESOLVER_TIMEOUT, tries: DNS_RESOLVER_TRIES}); + const resolver = new this.#dns.Resolver({timeout: DNS_RESOLVER_TIMEOUT_IN_MS, tries: DNS_RESOLVER_TRIES}); // On success, resolveSrv() returns an array of SrvRecord // On failure, resolveSrv() throws an error with code 'ENOTFOUND'. const originRecords = await resolver.resolveSrv(`${TCP_ORIGIN_KEY_NAME}.${host}`); // look up SRV records for the origin host @@ -266,7 +266,7 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat // retry options const defaultRetryOptions = { maxRetries: CLIENT_MAX_RETRIES, - maxRetryDelayInMs: CLIENT_MAX_RETRY_DELAY, + maxRetryDelayInMs: CLIENT_MAX_RETRY_DELAY_IN_MS, }; const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions); diff --git a/src/IKeyValueAdapter.ts b/src/IKeyValueAdapter.ts index 1f5042d6..222461dd 100644 --- a/src/IKeyValueAdapter.ts +++ b/src/IKeyValueAdapter.ts @@ -13,4 +13,9 @@ export interface IKeyValueAdapter { * This method process the original configuration setting, and returns processed key and value in an array. */ processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>; + + /** + * This method is called when a change is detected in the configuration setting. + */ + onChangeDetected(): Promise; } diff --git a/src/JsonKeyValueAdapter.ts b/src/JsonKeyValueAdapter.ts index 92f52f53..84266069 100644 --- a/src/JsonKeyValueAdapter.ts +++ b/src/JsonKeyValueAdapter.ts @@ -35,4 +35,8 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter { } return [setting.key, parsedValue]; } + + async onChangeDetected(): Promise { + return; + } } diff --git a/src/StartupOptions.ts b/src/StartupOptions.ts index f80644bb..5ab4f1a2 100644 --- a/src/StartupOptions.ts +++ b/src/StartupOptions.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100 * 1000; // 100 seconds in milliseconds +export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100_000; export interface StartupOptions { /** diff --git a/src/common/backoffUtils.ts b/src/common/backoffUtils.ts index 2bebf5c4..d0b78f39 100644 --- a/src/common/backoffUtils.ts +++ b/src/common/backoffUtils.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const MIN_BACKOFF_DURATION = 30_000; // 30 seconds in milliseconds -const MAX_BACKOFF_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds +const MIN_BACKOFF_DURATION_IN_MS = 30_000; +const MAX_BACKOFF_DURATION_IN_MS = 10 * 60 * 1000; const JITTER_RATIO = 0.25; export function getFixedBackoffDuration(timeElapsedInMs: number): number | undefined { @@ -13,21 +13,21 @@ export function getFixedBackoffDuration(timeElapsedInMs: number): number | undef return 10_000; } if (timeElapsedInMs < 10 * 60 * 1000) { - return MIN_BACKOFF_DURATION; + return MIN_BACKOFF_DURATION_IN_MS; } return undefined; } export function getExponentialBackoffDuration(failedAttempts: number): number { if (failedAttempts <= 1) { - return MIN_BACKOFF_DURATION; + return MIN_BACKOFF_DURATION_IN_MS; } // exponential: minBackoff * 2 ^ (failedAttempts - 1) // The right shift operator is not used in order to avoid potential overflow. Bitwise operations in JavaScript are limited to 32 bits. - let calculatedBackoffDuration = MIN_BACKOFF_DURATION * Math.pow(2, failedAttempts - 1); - if (calculatedBackoffDuration > MAX_BACKOFF_DURATION) { - calculatedBackoffDuration = MAX_BACKOFF_DURATION; + let calculatedBackoffDuration = MIN_BACKOFF_DURATION_IN_MS * Math.pow(2, failedAttempts - 1); + if (calculatedBackoffDuration > MAX_BACKOFF_DURATION_IN_MS) { + calculatedBackoffDuration = MAX_BACKOFF_DURATION_IN_MS; } // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs diff --git a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index d67fee34..8f5cc0f1 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -3,21 +3,21 @@ import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; import { IKeyValueAdapter } from "../IKeyValueAdapter.js"; +import { AzureKeyVaultSecretProvider } from "./AzureKeyVaultSecretProvider.js"; import { KeyVaultOptions } from "./KeyVaultOptions.js"; +import { RefreshTimer } from "../refresh/RefreshTimer.js"; import { ArgumentError, KeyVaultReferenceError } from "../common/error.js"; -import { KeyVaultSecretIdentifier, SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; +import { KeyVaultSecretIdentifier, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; import { isRestError } from "@azure/core-rest-pipeline"; import { AuthenticationError } from "@azure/identity"; export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { - /** - * Map vault hostname to corresponding secret client. - */ - #secretClients: Map; #keyVaultOptions: KeyVaultOptions | undefined; + #keyVaultSecretProvider: AzureKeyVaultSecretProvider; - constructor(keyVaultOptions: KeyVaultOptions | undefined) { + constructor(keyVaultOptions: KeyVaultOptions | undefined, refreshTimer?: RefreshTimer) { this.#keyVaultOptions = keyVaultOptions; + this.#keyVaultSecretProvider = new AzureKeyVaultSecretProvider(keyVaultOptions, refreshTimer); } canProcess(setting: ConfigurationSetting): boolean { @@ -25,7 +25,6 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { } async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { - // TODO: cache results to save requests. if (!this.#keyVaultOptions) { throw new ArgumentError("Failed to process the Key Vault reference because Key Vault options are not configured."); } @@ -39,53 +38,19 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { } try { - // precedence: secret clients > credential > secret resolver - const client = this.#getSecretClient(new URL(secretIdentifier.vaultUrl)); - if (client) { - const secret = await client.getSecret(secretIdentifier.name, { version: secretIdentifier.version }); - return [setting.key, secret.value]; - } - if (this.#keyVaultOptions.secretResolver) { - return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(secretIdentifier.sourceId))]; - } + 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 }); } throw error; } - - // When code reaches here, it means that the key vault reference cannot be resolved in all possible ways. - throw new ArgumentError("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); } - /** - * - * @param vaultUrl - The url of the key vault. - * @returns - */ - #getSecretClient(vaultUrl: URL): SecretClient | undefined { - if (this.#secretClients === undefined) { - this.#secretClients = new Map(); - for (const client of this.#keyVaultOptions?.secretClients ?? []) { - const clientUrl = new URL(client.vaultUrl); - this.#secretClients.set(clientUrl.host, client); - } - } - - let client: SecretClient | undefined; - client = this.#secretClients.get(vaultUrl.host); - if (client !== undefined) { - return client; - } - - if (this.#keyVaultOptions?.credential) { - client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential, this.#keyVaultOptions.clientOptions); - this.#secretClients.set(vaultUrl.host, client); - return client; - } - - return undefined; + async onChangeDetected(): Promise { + this.#keyVaultSecretProvider.clearCache(); + return; } } diff --git a/src/keyvault/AzureKeyVaultSecretProvider.ts b/src/keyvault/AzureKeyVaultSecretProvider.ts new file mode 100644 index 00000000..546b4be5 --- /dev/null +++ b/src/keyvault/AzureKeyVaultSecretProvider.ts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { KeyVaultOptions } from "./KeyVaultOptions.js"; +import { RefreshTimer } from "../refresh/RefreshTimer.js"; +import { ArgumentError } from "../common/error.js"; +import { SecretClient, KeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; + +export class AzureKeyVaultSecretProvider { + #keyVaultOptions: KeyVaultOptions | undefined; + #secretRefreshTimer: RefreshTimer | undefined; + #secretClients: Map; // map key vault hostname to corresponding secret client + #cachedSecretValues: Map = new Map(); // map secret identifier to secret value + + constructor(keyVaultOptions: KeyVaultOptions | undefined, refreshTimer?: RefreshTimer) { + if (keyVaultOptions?.secretRefreshIntervalInMs !== undefined) { + if (refreshTimer === undefined) { + throw new ArgumentError("Refresh timer must be specified when Key Vault secret refresh is enabled."); + } + if (refreshTimer.interval !== keyVaultOptions.secretRefreshIntervalInMs) { + throw new ArgumentError("Refresh timer does not match the secret refresh interval."); + } + } + this.#keyVaultOptions = keyVaultOptions; + this.#secretRefreshTimer = refreshTimer; + this.#secretClients = new Map(); + for (const client of this.#keyVaultOptions?.secretClients ?? []) { + const clientUrl = new URL(client.vaultUrl); + this.#secretClients.set(clientUrl.host, client); + } + } + + async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise { + 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); + } + + // Fallback to fetching the secret value from Key Vault. + const secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier); + this.#cachedSecretValues.set(identifierKey, secretValue); + return secretValue; + } + + clearCache(): void { + this.#cachedSecretValues.clear(); + } + + async #getSecretValueFromKeyVault(secretIdentifier: KeyVaultSecretIdentifier): Promise { + if (!this.#keyVaultOptions) { + throw new ArgumentError("Failed to get secret value. The keyVaultOptions is not configured."); + } + const { name: secretName, vaultUrl, sourceId, version } = secretIdentifier; + // precedence: secret clients > custom secret resolver + const client = this.#getSecretClient(new URL(vaultUrl)); + if (client) { + const secret = await client.getSecret(secretName, { version }); + return secret.value; + } + if (this.#keyVaultOptions.secretResolver) { + return await this.#keyVaultOptions.secretResolver(new URL(sourceId)); + } + // When code reaches here, it means that the key vault reference cannot be resolved in all possible ways. + throw new ArgumentError("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); + } + + #getSecretClient(vaultUrl: URL): SecretClient | undefined { + let client = this.#secretClients.get(vaultUrl.host); + if (client !== undefined) { + return client; + } + if (this.#keyVaultOptions?.credential) { + client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential, this.#keyVaultOptions.clientOptions); + this.#secretClients.set(vaultUrl.host, client); + return client; + } + return undefined; + } +} diff --git a/src/keyvault/KeyVaultOptions.ts b/src/keyvault/KeyVaultOptions.ts index 3cf4bad0..7f960872 100644 --- a/src/keyvault/KeyVaultOptions.ts +++ b/src/keyvault/KeyVaultOptions.ts @@ -4,6 +4,8 @@ import { TokenCredential } from "@azure/identity"; import { SecretClient, SecretClientOptions } from "@azure/keyvault-secrets"; +export const MIN_SECRET_REFRESH_INTERVAL_IN_MS = 60_000; + /** * Options used to resolve Key Vault references. */ @@ -19,7 +21,7 @@ export interface KeyVaultOptions { credential?: TokenCredential; /** - * Configures the client options used when connecting to key vaults that have no registered SecretClient. + * * Configures the client options used when connecting to key vaults that have no registered SecretClient. * * @remarks * The client options will not affect the registered SecretClient instances. @@ -40,4 +42,12 @@ export interface KeyVaultOptions { * If not specified, the default value is false. */ parallelSecretResolutionEnabled?: boolean; + + /** + * Specifies the refresh interval in milliseconds for periodically reloading all secrets from Key Vault. + * + * @remarks + * If specified, the value must be greater than 60 seconds. + */ + secretRefreshIntervalInMs?: number; } diff --git a/src/load.ts b/src/load.ts index 25fd9594..15f88218 100644 --- a/src/load.ts +++ b/src/load.ts @@ -8,7 +8,7 @@ import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js" import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; import { instanceOfTokenCredential } from "./common/utils.js"; -const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5_000; // 5 seconds +const MIN_DELAY_FOR_UNHANDLED_ERROR_IN_MS: number = 5_000; /** * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. @@ -49,7 +49,7 @@ export async function load( // load() method is called in the application's startup code path. // Unhandled exceptions cause application crash which can result in crash loops as orchestrators attempt to restart the application. // Knowing the intended usage of the provider in startup code path, we mitigate back-to-back crash loops from overloading the server with requests by waiting a minimum time to propagate fatal errors. - const delay = MIN_DELAY_FOR_UNHANDLED_ERROR - (Date.now() - startTimestamp); + const delay = MIN_DELAY_FOR_UNHANDLED_ERROR_IN_MS - (Date.now() - startTimestamp); if (delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); } diff --git a/src/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts index cf4deca5..2c77df53 100644 --- a/src/refresh/RefreshTimer.ts +++ b/src/refresh/RefreshTimer.ts @@ -3,15 +3,15 @@ export class RefreshTimer { #backoffEnd: number; // Timestamp - #interval: number; + readonly interval: number; constructor(interval: number) { if (interval <= 0) { throw new RangeError(`Refresh interval must be greater than 0. Given: ${interval}`); } - this.#interval = interval; - this.#backoffEnd = Date.now() + this.#interval; + this.interval = interval; + this.#backoffEnd = Date.now() + this.interval; } canRefresh(): boolean { @@ -19,6 +19,6 @@ export class RefreshTimer { } reset(): void { - this.#backoffEnd = Date.now() + this.#interval; + this.#backoffEnd = Date.now() + this.interval; } } diff --git a/src/refresh/refreshOptions.ts b/src/refresh/refreshOptions.ts index 202c7340..9b82b6d9 100644 --- a/src/refresh/refreshOptions.ts +++ b/src/refresh/refreshOptions.ts @@ -3,8 +3,8 @@ import { WatchedSetting } from "../WatchedSetting.js"; -export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30 * 1000; -export const MIN_REFRESH_INTERVAL_IN_MS = 1 * 1000; +export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30_000; +export const MIN_REFRESH_INTERVAL_IN_MS = 1_000; export interface RefreshOptions { /** diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index cfed8317..6f9311b4 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -49,6 +49,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount"; // Tag names export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; +export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault"; export const FAILOVER_REQUEST_TAG = "Failover"; // Compact feature tags diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index af6ef0b8..ada90382 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -19,6 +19,7 @@ import { HOST_TYPE_KEY, HostType, KEY_VAULT_CONFIGURED_TAG, + KEY_VAULT_REFRESH_CONFIGURED_TAG, KUBERNETES_ENV_VAR, NODEJS_DEV_ENV_VAL, NODEJS_ENV_VAR, @@ -121,10 +122,13 @@ function createCorrelationContextHeader(requestTracingOptions: RequestTracingOpt const appConfigOptions = requestTracingOptions.appConfigOptions; if (appConfigOptions?.keyVaultOptions) { - const { credential, secretClients, secretResolver } = appConfigOptions.keyVaultOptions; + const { credential, secretClients, secretRefreshIntervalInMs, secretResolver } = appConfigOptions.keyVaultOptions; if (credential !== undefined || secretClients?.length || secretResolver !== undefined) { tags.push(KEY_VAULT_CONFIGURED_TAG); } + if (secretRefreshIntervalInMs !== undefined) { + tags.push(KEY_VAULT_REFRESH_CONFIGURED_TAG); + } } const featureFlagTracing = requestTracingOptions.featureFlagTracing; diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index 8fd15a19..a48d633e 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; -import { MAX_TIME_OUT, sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference, sleepInMs } from "./utils/testHelper.js"; import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; const mockedData = [ @@ -140,3 +140,62 @@ describe("key vault reference", function () { expect(settings.get("TestKeyFixedVersion")).eq("OldSecretValue"); }); }); + +describe("key vault secret refresh", function () { + this.timeout(MAX_TIME_OUT); + + beforeEach(() => { + const data = [ + ["TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName", "SecretValue"] + ]; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const kvs = data.map(([key, vaultUri, _value]) => createMockedKeyVaultReference(key, vaultUri)); + mockAppConfigurationClientListConfigurationSettings([kvs]); + }); + + afterEach(() => { + restoreMocks(); + }); + + it("should not allow secret refresh interval less than 1 minute", async () => { + const connectionString = createMockedConnectionString(); + const loadWithInvalidSecretRefreshInterval = load(connectionString, { + keyVaultOptions: { + secretClients: [ + new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), + ], + secretRefreshIntervalInMs: 59999 // less than 60_000 milliseconds + } + }); + return expect(loadWithInvalidSecretRefreshInterval).eventually.rejectedWith("The Key Vault secret refresh interval cannot be less than 60000 milliseconds."); + }); + + it("should reload key vault secret when there is no change to key-values", async () => { + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + const stub = sinon.stub(client, "getSecret"); + stub.onCall(0).resolves({ value: "SecretValue" } as KeyVaultSecret); + stub.onCall(1).resolves({ value: "SecretValue - Updated" } as KeyVaultSecret); + + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + client + ], + credential: createMockedTokenCredential(), + secretRefreshIntervalInMs: 60_000 + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretValue"); + + await sleepInMs(30_000); + await settings.refresh(); + // use cached value + expect(settings.get("TestKey")).eq("SecretValue"); + + await sleepInMs(30_000); + await settings.refresh(); + // secret refresh interval expires, reload secret value + expect(settings.get("TestKey")).eq("SecretValue - Updated"); + }); +}); diff --git a/test/refresh.test.ts b/test/refresh.test.ts index d03d9436..704d6c21 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -55,7 +55,7 @@ describe("dynamic refresh", function () { const connectionString = createMockedConnectionString(); const settings = await load(connectionString); const refreshCall = settings.refresh(); - return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled for key-values or feature flags."); + return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled for key-values, feature flags or Key Vault secrets."); }); it("should not allow refresh interval less than 1 second", async () => { @@ -117,7 +117,7 @@ describe("dynamic refresh", function () { it("should throw error when calling onRefresh when refresh is not enabled", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString); - expect(() => settings.onRefresh(() => { })).throws("Refresh is not enabled for key-values or feature flags."); + expect(() => settings.onRefresh(() => { })).throws("Refresh is not enabled for key-values, feature flags or Key Vault secrets."); }); it("should only update values after refreshInterval", async () => { @@ -438,7 +438,7 @@ describe("dynamic refresh", function () { }); describe("dynamic refresh feature flags", function () { - this.timeout(10000); + this.timeout(MAX_TIME_OUT); beforeEach(() => { }); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 6b1baca4..60f629fc 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -13,7 +13,7 @@ import * as crypto from "crypto"; import { ConfigurationClientManager } from "../../src/ConfigurationClientManager.js"; import { ConfigurationClientWrapper } from "../../src/ConfigurationClientWrapper.js"; -const MAX_TIME_OUT = 20000; +const MAX_TIME_OUT = 100_000; const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000";