Skip to content

Commit ddc4a50

Browse files
add secret provider
1 parent 190f7b1 commit ddc4a50

File tree

12 files changed

+138
-73
lines changed

12 files changed

+138
-73
lines changed

src/AzureAppConfigurationImpl.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag } from "@azure/app-configuration";
4+
import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag, isSecretReference } from "@azure/app-configuration";
55
import { isRestError } from "@azure/core-rest-pipeline";
66
import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration.js";
77
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js";
88
import { IKeyValueAdapter } from "./IKeyValueAdapter.js";
99
import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js";
1010
import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js";
1111
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js";
12+
import { MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyvault/KeyVaultOptions.js";
1213
import { Disposable } from "./common/disposable.js";
1314
import { base64Helper, jsonSorter } from "./common/utils.js";
1415
import {
@@ -87,6 +88,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
8788
#ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS;
8889
#ffRefreshTimer: RefreshTimer;
8990

91+
// Key Vault references
92+
#secretRefreshEnabled: boolean = false;
93+
#secretReferences: ConfigurationSetting[] = []; // cached key vault references
94+
#secretRefreshTimer: RefreshTimer;
9095
/**
9196
* Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors
9297
*/
@@ -139,9 +144,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
139144
if (refreshIntervalInMs !== undefined) {
140145
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
141146
throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
142-
} else {
143-
this.#kvRefreshInterval = refreshIntervalInMs;
144147
}
148+
this.#kvRefreshInterval = refreshIntervalInMs;
145149
}
146150
this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval);
147151
}
@@ -157,16 +161,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
157161
if (refreshIntervalInMs !== undefined) {
158162
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
159163
throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
160-
} else {
161-
this.#ffRefreshInterval = refreshIntervalInMs;
162164
}
165+
this.#ffRefreshInterval = refreshIntervalInMs;
163166
}
164167

165168
this.#ffRefreshTimer = new RefreshTimer(this.#ffRefreshInterval);
166169
}
167170
}
168171

169-
this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions));
172+
if (options?.keyVaultOptions) {
173+
const { secretRefreshIntervalInMs } = options.keyVaultOptions;
174+
if (secretRefreshIntervalInMs !== undefined) {
175+
if (secretRefreshIntervalInMs < MIN_SECRET_REFRESH_INTERVAL_IN_MS) {
176+
throw new RangeError(`The key vault secret refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
177+
}
178+
this.#secretRefreshEnabled = true;
179+
this.#secretRefreshTimer = new RefreshTimer(secretRefreshIntervalInMs);
180+
}
181+
}
182+
this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions, this.#secretRefreshTimer));
170183
this.#adapters.push(new JsonKeyValueAdapter());
171184
}
172185

@@ -305,8 +318,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
305318
* Refreshes the configuration.
306319
*/
307320
async refresh(): Promise<void> {
308-
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
309-
throw new OperationError("Refresh is not enabled for key-values or feature flags.");
321+
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled && !this.#secretRefreshEnabled) {
322+
throw new OperationError("Refresh is not enabled for key-values, key vault secrets or feature flags.");
310323
}
311324

312325
if (this.#refreshInProgress) {
@@ -324,8 +337,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
324337
* Registers a callback function to be called when the configuration is refreshed.
325338
*/
326339
onRefresh(listener: () => any, thisArg?: any): Disposable {
327-
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
328-
throw new OperationError("Refresh is not enabled for key-values or feature flags.");
340+
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled && !this.#secretRefreshEnabled) {
341+
throw new OperationError("Refresh is not enabled for key-values, key vault secrets or feature flags.");
329342
}
330343

331344
const boundedListener = listener.bind(thisArg);
@@ -399,6 +412,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
399412
if (this.#featureFlagRefreshEnabled) {
400413
refreshTasks.push(this.#refreshFeatureFlags());
401414
}
415+
if (this.#secretRefreshEnabled) {
416+
refreshTasks.push(this.#refreshSecrets());
417+
}
402418

403419
// wait until all tasks are either resolved or rejected
404420
const results = await Promise.allSettled(refreshTasks);
@@ -481,8 +497,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
481497
await this.#updateWatchedKeyValuesEtag(loadedSettings);
482498
}
483499

484-
// adapt configuration settings to key-values
500+
// clear all cached key vault references
501+
this.#secretReferences = [];
485502
for (const setting of loadedSettings) {
503+
if (this.#secretRefreshEnabled && isSecretReference(setting)) {
504+
this.#secretReferences.push(setting);
505+
}
486506
const [key, value] = await this.#processKeyValues(setting);
487507
keyValues.push([key, value]);
488508
}
@@ -597,6 +617,21 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
597617
return Promise.resolve(needRefresh);
598618
}
599619

620+
async #refreshSecrets(): Promise<boolean> {
621+
// if still within refresh interval/backoff, return
622+
if (!this.#secretRefreshTimer.canRefresh()) {
623+
return Promise.resolve(false);
624+
}
625+
626+
for (const setting of this.#secretReferences) {
627+
const [key, value] = await this.#processKeyValues(setting);
628+
this.#configMap.set(key, value);
629+
}
630+
631+
this.#secretRefreshTimer.reset();
632+
return Promise.resolve(true);
633+
}
634+
600635
/**
601636
* Checks whether the key-value collection has changed.
602637
* @param selectors - The @see PagedSettingSelector of the kev-value collection.

src/StartupOptions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100 * 1000; // 100 seconds in milliseconds
4+
export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100_000; // 100 seconds in milliseconds
55

66
export interface StartupOptions {
77
/**

src/keyvault/AzureKeyVaultKeyValueAdapter.ts

Lines changed: 12 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,79 +3,42 @@
33

44
import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration";
55
import { IKeyValueAdapter } from "../IKeyValueAdapter.js";
6+
import { AzureKeyVaultSecretProvider } from "./AzureKeyVaultSecretProvider.js";
67
import { KeyVaultOptions } from "./KeyVaultOptions.js";
7-
import { getUrlHost } from "../common/utils.js";
8+
import { RefreshTimer } from "../refresh/RefreshTimer.js";
89
import { ArgumentError, KeyVaultReferenceError } from "../error.js";
9-
import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets";
10+
import { parseKeyVaultSecretIdentifier, KeyVaultSecretIdentifier } from "@azure/keyvault-secrets";
1011

1112
export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
12-
/**
13-
* Map vault hostname to corresponding secret client.
14-
*/
15-
#secretClients: Map<string, SecretClient>;
1613
#keyVaultOptions: KeyVaultOptions | undefined;
14+
#keyVaultSecretProvider: AzureKeyVaultSecretProvider;
1715

18-
constructor(keyVaultOptions: KeyVaultOptions | undefined) {
16+
constructor(keyVaultOptions: KeyVaultOptions | undefined, refreshTimer?: RefreshTimer) {
1917
this.#keyVaultOptions = keyVaultOptions;
18+
this.#keyVaultSecretProvider = new AzureKeyVaultSecretProvider(keyVaultOptions, refreshTimer);
2019
}
2120

2221
canProcess(setting: ConfigurationSetting): boolean {
2322
return isSecretReference(setting);
2423
}
2524

2625
async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> {
27-
// TODO: cache results to save requests.
2826
if (!this.#keyVaultOptions) {
2927
throw new ArgumentError("Failed to process the key vault reference. The keyVaultOptions is not configured.");
3028
}
3129

32-
const { name: secretName, vaultUrl, sourceId, version } = parseKeyVaultSecretIdentifier(
30+
const secretIdentifier: KeyVaultSecretIdentifier = parseKeyVaultSecretIdentifier(
3331
parseSecretReference(setting).value.secretId
3432
);
3533
try {
36-
// precedence: secret clients > credential > secret resolver
37-
const client = this.#getSecretClient(new URL(vaultUrl));
38-
if (client) {
39-
const secret = await client.getSecret(secretName, { version });
40-
return [setting.key, secret.value];
41-
}
42-
if (this.#keyVaultOptions.secretResolver) {
43-
return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(sourceId))];
44-
}
34+
const secretValue = await this.#keyVaultSecretProvider.getSecretValue(secretIdentifier);
35+
return [setting.key, secretValue];
4536
} catch (error) {
46-
throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage(error.message, setting, sourceId));
47-
}
48-
49-
// When code reaches here, it means that the key vault reference cannot be resolved in all possible ways.
50-
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.");
51-
}
52-
53-
/**
54-
*
55-
* @param vaultUrl - The url of the key vault.
56-
* @returns
57-
*/
58-
#getSecretClient(vaultUrl: URL): SecretClient | undefined {
59-
if (this.#secretClients === undefined) {
60-
this.#secretClients = new Map();
61-
for (const client of this.#keyVaultOptions?.secretClients ?? []) {
62-
this.#secretClients.set(getUrlHost(client.vaultUrl), client);
37+
if (error instanceof ArgumentError) {
38+
throw error;
6339
}
40+
throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage(error.message, setting, secretIdentifier.sourceId));
6441
}
65-
66-
let client: SecretClient | undefined;
67-
client = this.#secretClients.get(vaultUrl.host);
68-
if (client !== undefined) {
69-
return client;
70-
}
71-
72-
if (this.#keyVaultOptions?.credential) {
73-
client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential);
74-
this.#secretClients.set(vaultUrl.host, client);
75-
return client;
76-
}
77-
78-
return undefined;
7942
}
8043
}
8144

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { KeyVaultOptions } from "./KeyVaultOptions.js";
5+
import { RefreshTimer } from "../refresh/RefreshTimer.js";
6+
import { getUrlHost } from "../common/utils.js";
7+
import { ArgumentError } from "../error.js";
8+
import { SecretClient, KeyVaultSecretIdentifier } from "@azure/keyvault-secrets";
9+
10+
export class AzureKeyVaultSecretProvider {
11+
#keyVaultOptions: KeyVaultOptions | undefined;
12+
#secretClients: Map<string, SecretClient>; // map key vault hostname to corresponding secret client
13+
14+
constructor(keyVaultOptions: KeyVaultOptions | undefined, refreshTimer?: RefreshTimer) {
15+
if (keyVaultOptions?.secretRefreshIntervalInMs !== undefined) {
16+
if (refreshTimer === undefined) {
17+
throw new ArgumentError("Refresh timer must be specified when Key Vault secret refresh is enabled.");
18+
}
19+
if (refreshTimer.interval !== keyVaultOptions.secretRefreshIntervalInMs) {
20+
throw new ArgumentError("Refresh timer does not match the secret refresh interval.");
21+
}
22+
}
23+
this.#keyVaultOptions = keyVaultOptions;
24+
this.#secretClients = new Map();
25+
for (const client of this.#keyVaultOptions?.secretClients ?? []) {
26+
this.#secretClients.set(getUrlHost(client.vaultUrl), client);
27+
}
28+
}
29+
30+
async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise<unknown> {
31+
if (!this.#keyVaultOptions) {
32+
throw new ArgumentError("Failed to get secret value. The keyVaultOptions is not configured.");
33+
}
34+
const { name: secretName, vaultUrl, sourceId, version } = secretIdentifier;
35+
// precedence: secret clients > custom secret resolver
36+
const client = this.#getSecretClient(new URL(vaultUrl));
37+
if (client) {
38+
const secret = await client.getSecret(secretName, { version });
39+
return secret.value;
40+
}
41+
if (this.#keyVaultOptions.secretResolver) {
42+
return await this.#keyVaultOptions.secretResolver(new URL(sourceId));
43+
}
44+
// When code reaches here, it means that the key vault reference cannot be resolved in all possible ways.
45+
throw new ArgumentError("Failed to get secret value. No key vault secret client, credential or secret resolver callback is available to resolve the secret.");
46+
}
47+
48+
#getSecretClient(vaultUrl: URL): SecretClient | undefined {
49+
let client = this.#secretClients.get(vaultUrl.host);
50+
if (client !== undefined) {
51+
return client;
52+
}
53+
if (this.#keyVaultOptions?.credential) {
54+
client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential);
55+
this.#secretClients.set(vaultUrl.host, client);
56+
return client;
57+
}
58+
59+
return undefined;
60+
}
61+
}

src/keyvault/KeyVaultOptions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import { TokenCredential } from "@azure/identity";
55
import { SecretClient } from "@azure/keyvault-secrets";
66

7+
export const MIN_SECRET_REFRESH_INTERVAL_IN_MS = 60_000;
8+
79
/**
810
* Options used to resolve Key Vault references.
911
*/

src/refresh/RefreshTimer.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,22 @@
33

44
export class RefreshTimer {
55
#backoffEnd: number; // Timestamp
6-
#interval: number;
6+
readonly interval: number;
77

88
constructor(interval: number) {
99
if (interval <= 0) {
10-
throw new RangeError(`Refresh interval must be greater than 0. Given: ${this.#interval}`);
10+
throw new RangeError(`Refresh interval must be greater than 0. Given: ${this.interval}`);
1111
}
1212

13-
this.#interval = interval;
14-
this.#backoffEnd = Date.now() + this.#interval;
13+
this.interval = interval;
14+
this.#backoffEnd = Date.now() + this.interval;
1515
}
1616

1717
canRefresh(): boolean {
1818
return Date.now() >= this.#backoffEnd;
1919
}
2020

2121
reset(): void {
22-
this.#backoffEnd = Date.now() + this.#interval;
22+
this.#backoffEnd = Date.now() + this.interval;
2323
}
2424
}

src/refresh/refreshOptions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
import { WatchedSetting } from "../WatchedSetting.js";
55

6-
export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30 * 1000;
7-
export const MIN_REFRESH_INTERVAL_IN_MS = 1 * 1000;
6+
export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30_000;
7+
export const MIN_REFRESH_INTERVAL_IN_MS = 1_000;
88

99
export interface RefreshOptions {
1010
/**

src/requestTracing/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount";
4949

5050
// Tag names
5151
export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault";
52+
export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault";
5253
export const FAILOVER_REQUEST_TAG = "Failover";
5354

5455
// Compact feature tags

src/requestTracing/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
HOST_TYPE_KEY,
1818
HostType,
1919
KEY_VAULT_CONFIGURED_TAG,
20+
KEY_VAULT_REFRESH_CONFIGURED_TAG,
2021
KUBERNETES_ENV_VAR,
2122
NODEJS_DEV_ENV_VAL,
2223
NODEJS_ENV_VAR,
@@ -100,10 +101,13 @@ export function createCorrelationContextHeader(requestTracingOptions: RequestTra
100101

101102
const appConfigOptions = requestTracingOptions.appConfigOptions;
102103
if (appConfigOptions?.keyVaultOptions) {
103-
const { credential, secretClients, secretResolver } = appConfigOptions.keyVaultOptions;
104+
const { credential, secretClients, secretRefreshIntervalInMs, secretResolver } = appConfigOptions.keyVaultOptions;
104105
if (credential !== undefined || secretClients?.length || secretResolver !== undefined) {
105106
tags.push(KEY_VAULT_CONFIGURED_TAG);
106107
}
108+
if (secretRefreshIntervalInMs !== undefined) {
109+
tags.push(KEY_VAULT_REFRESH_CONFIGURED_TAG);
110+
}
107111
}
108112

109113
const featureFlagTracing = requestTracingOptions.featureFlagTracing;

test/keyvault.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe("key vault reference", function () {
9696
]
9797
}
9898
});
99-
return expect(loadKeyVaultPromise).eventually.rejectedWith("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret.");
99+
return expect(loadKeyVaultPromise).eventually.rejectedWith("Failed to get secret value. No key vault secret client, credential or secret resolver callback is available to resolve the secret.");
100100
});
101101

102102
it("should fallback to use default credential when corresponding secret client not provided", async () => {

0 commit comments

Comments
 (0)