Skip to content

Commit 34e3318

Browse files
siyixianJames XianCopilot
authored
Control credential caches through environment variables (#40)
Co-authored-by: James Xian <jamesxian@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 36db07b commit 34e3318

26 files changed

Lines changed: 723 additions & 132 deletions

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ The single-region request flow is:
5050

5151
The diagram's delivery path describes **live requests**. An authorized, valid encrypted
5252
**evaluation request (`mode: 2`)** returns the matching nonce without submitting a message to the
53-
provider. Background credential refresh can still run independently. Authentication failures may
53+
provider. With caching enabled, background credential refresh can still run independently. Authentication failures may
5454
return **401 or 403**; neither is a successful evaluation.
5555

5656
**East US in the diagram is illustrative, not a required or guaranteed deployment location.**
@@ -231,7 +231,8 @@ not just a locally running Function.
231231
| Controlled live test | The provider accepts the selected SMS or voice request and the test recipient receives the message or call. Provider acceptance alone is not proof of delivery. |
232232
| Operational visibility | Review Application Insights for the request outcome without recording phone numbers, message bodies, tokens, private keys, or nonce values in shared logs. |
233233

234-
Configured workers can acquire credentials at startup without sending an OTP; JavaScript/Python
234+
With the selected [credential cache](docs/CONTRACT.md#credential-caching-and-refresh) enabled,
235+
configured workers can acquire credentials at startup without sending an OTP; JavaScript/Python
235236
also poll for refresh, whereas .NET retrieves replacements on cache misses. Check collected
236237
`credential_refresh_failed` warnings before live testing; an evaluation success or absence of
237238
warnings does not validate provider credentials.

‎TECHNICAL.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ how code accesses configuration, not the environment-variable names.
178178
| `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE` | Soprano OAuth | Provider tenant and selected API scope. |
179179
| `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | Soprano OAuth | Existing multitenant application and outbound user-assigned managed identity used for client-assertion exchange. |
180180
| `EPP_PROVIDER_TIMEOUT_MS` | Optional | Decimal milliseconds. Defaults to `1500`, capped at `2500`; not an end-to-end deadline. |
181+
| `EPP_KEY_VAULT_CACHE_ENABLED` | Optional | `true` enables provider API-key bundle caching; `false` reads the bundle for each live request. Unset defaults to `true`. |
182+
| `EPP_ACCESS_TOKEN_CACHE_ENABLED` | Optional | `true` enables OAuth credential caching; `false` uses request-scoped MI/client-assertion credentials. Unset defaults to `true`. |
181183
| `EPP_PROVIDER_ACCOUNT_NAME` | Adapter-dependent | Sender/account metadata, not an API key or credential identity. |
182184
| `KEY_VAULT_URL` | Provider credential lookup | URI of the vault containing the manifest-named provider secrets. Separate from the encryption-key reference. |
183185
| `AZURE_CLIENT_ID` | Optional | User-assigned managed identity's client ID for Key Vault. Leave unset for system-assigned identity. |
@@ -198,13 +200,18 @@ login; ordinary local machines have no managed-identity endpoint. Use offline te
198200
evaluation locally, or an explicitly injected test resolver for integration work. Never commit local
199201
settings, keys or test credentials.
200202

201-
Configured providers are [prepared per worker](docs/CONTRACT.md#credential-caching-and-refresh).
203+
When their selected cache is enabled, configured providers are
204+
[prepared per worker](docs/CONTRACT.md#credential-caching-and-refresh).
202205
JavaScript/Python warm credentials and poll for refresh; .NET warms at startup and retrieves
203206
replacements on cache misses, without a periodic poller. Credential acquisition never sends an OTP.
204207
Evaluation skips provider work, but configured workers can independently acquire credentials at
205-
startup. Leave `EPP_PROVIDER_NAME` unset for local evaluation-only work without credential acquisition.
206-
The setup-written cache switches do not change current checked-in runtime behavior; verify your
207-
selected package rather than assuming plan selection enables/disables caching.
208+
startup. Disabling the selected cache skips startup preparation, polling, and cross-request reuse,
209+
but live requests still acquire credentials. Each switch accepts trimmed, case-insensitive `true` or
210+
`false`; an invalid selected value fails live credential resolution closed without preventing evaluation.
211+
The other provider-auth mode's switch is ignored. Setup writes both as `false` for FC1 or `true` for
212+
EP1; older packages that do not read these settings still require a supporting release. Restart after
213+
changes. Leave `EPP_PROVIDER_NAME` unset or disable its cache for local evaluation-only work without
214+
credential acquisition. Platform-managed identity caching and decryption-key references are separate.
208215

209216
Core Tools does not resolve Azure Key Vault reference expressions locally. Supply the local test PEM
210217
or base64 PEM directly; use a reference such as `@Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/<private-key-secret>/)`

‎docs/CONTRACT.md‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ there is no API-key fallback. Evaluation skips acquisition. A provider rejection
125125
Tokens are treated as opaque: the Function checks SDK expiry metadata, not custom JWT claims.
126126
Soprano remains responsible for signature, issuer, audience, expiry, permissions, and account validation.
127127

128-
Credential instances and their SDK caches are reused for the configured tenant/application/identity.
128+
With `EPP_ACCESS_TOKEN_CACHE_ENABLED=true` (the default), credential instances and their SDK caches
129+
are reused for the configured tenant/application/identity.
129130
JavaScript/Python use a [worker-local refresh loop](#credential-caching-and-refresh) for both
130131
exchange stages. .NET warms at startup and fetches a replacement on a cache miss; it has no poller.
131132
JavaScript bounds shared acquisition to 2.5 seconds independently of individual waiters;
@@ -135,7 +136,7 @@ in the installed SDK. Python bounds caller waits and SDK connect/read inactivity
135136
shared synchronous retrieval may finish after a waiter leaves. It uses `get_token_info` for refresh
136137
hints when supported, otherwise `get_token`; a failed acquisition never falls back to another API.
137138

138-
Credential SDK transport retries are disabled. JavaScript/Python failed refreshes use the polling
139+
Credential SDK transport retries are disabled. With caching enabled, JavaScript/Python failed refreshes use the polling
139140
cadence below; .NET retries credential acquisition on a later cache miss. These are not end-to-end
140141
delivery deadlines. JavaScript suppresses SDK logs in the
141142
acquisition's asynchronous context. Python filters Azure Identity/Core/MSAL records on configured
@@ -200,9 +201,10 @@ are needed. Platform authentication and resolution of the decryption-key referen
200201
network access. Core Tools has no Easy Auth; local evaluation must remain loopback-only, without tunnels.
201202

202203
This describes the evaluation **request path**. Independently, workers with a configured provider
203-
automatically prewarm and refresh credentials, even if their current traffic is evaluation-only.
204+
and its cache enabled prepare credentials at startup; JavaScript/Python also poll for refresh,
205+
even if their current traffic is evaluation-only.
204206
No background task dispatches an OTP. A worker without `EPP_PROVIDER_NAME` performs no credential
205-
prewarming, and evaluation does not require that prewarming succeed.
207+
prewarming. Disabling the selected cache also suppresses this preparation; evaluation never requires it to succeed.
206208

207209
There is no diagnostic environment flag. A live request is not an evaluation request. Adapter-specific
208210
wire fields, where required by an API, remain internal and cannot enable a separate non-delivery mode.
@@ -301,6 +303,8 @@ Set by provisioning. **Identical names across all languages.**
301303
| `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | client application and user-assigned identity used for Soprano client-assertion exchange |
302304
| `EPP_PROVIDER_ACCOUNT_NAME` | sender/source only when required by the selected provider |
303305
| `EPP_PROVIDER_TIMEOUT_MS` | trimmed ASCII decimal milliseconds; default 1500 for missing/invalid/nonpositive values; capped at 2500. Not a whole-invocation deadline |
306+
| `EPP_KEY_VAULT_CACHE_ENABLED` | `true`/`false`: API-key bundle caching and startup/refresh; unset defaults to `true` |
307+
| `EPP_ACCESS_TOKEN_CACHE_ENABLED` | `true`/`false`: OAuth credential caching and startup/refresh; unset defaults to `true` |
304308
| `EPP_DECRYPTION_KEY_PEM` | single RSA private key for JWE decryption, PEM or base64-encoded PEM; use a Key Vault secret reference in Azure, not a plaintext private key in shared settings |
305309
| `EPP_ENCRYPTION_KEY_ID` | optional expected JWE `kid`; after successful decryption, a mismatch emits only `encryption_key_id_mismatch`. Advisory, not a key selector or authentication check |
306310
| `KEY_VAULT_URL` | Key Vault URI for API-key providers |
@@ -342,10 +346,22 @@ subscription activation and changing tenant policy belong to provisioning, not t
342346

343347
Provider credentials are process-local, distinct from the platform-resolved decryption-key
344348
reference. Credential acquisition never sends an OTP or changes caller authentication.
345-
Restart workers after configuration changes. The setup-written
346-
`EPP_KEY_VAULT_CACHE_ENABLED` / `EPP_ACCESS_TOKEN_CACHE_ENABLED` switches are not read by the
347-
current checked-in implementations; verify the selected release before relying on plan-specific
348-
cache control.
349+
All runtimes use `EPP_KEY_VAULT_CACHE_ENABLED` for the selected `apiKey` provider or
350+
`EPP_ACCESS_TOKEN_CACHE_ENABLED` for the selected `oauth` provider. The switches are independent;
351+
runtime selection does not depend on the hosting plan. Unset defaults to enabled. Values accept
352+
trimmed, case-insensitive `true` or `false`; blank or other explicit selected values fail live
353+
credential acquisition closed with a sanitized warning, without blocking evaluation.
354+
Restart workers after configuration changes. Setup writes both as `false` for FC1 or `true` for EP1;
355+
deploy a supporting package, since older releases do not read these settings.
356+
357+
With caching **disabled**, each live request retrieves a complete Key Vault bundle or uses fresh
358+
managed-identity/client-assertion SDK credentials for OAuth. There is no startup preparation,
359+
periodic polling, cross-request credential sharing, or failure cooldown. Request-scoped state is
360+
discarded after acquisition; Python closes its SDK clients when synchronous acquisition finishes.
361+
The same expiry checks and acquisition budgets below still apply. Azure's managed-identity service
362+
and platform Key Vault-reference caching remain outside these switches.
363+
364+
With caching **enabled**, each runtime retains its existing policy:
349365

350366
| Runtime | Startup and replacement behavior | Operator consequence |
351367
|---|---|---|
@@ -365,8 +381,8 @@ transport. Python bounds waits and SDK connect/read inactivity to 2.5 seconds bu
365381
cancel synchronous I/O. .NET uses a 2.5-second fetch budget linked to the fetch caller's cancellation
366382
token. None is a whole-invocation deadline.
367383

368-
All runtimes fetch a complete API-key/customer-ID bundle before caching it and use managed identity
369-
for vault access. Soprano reuses the managed-identity and client-assertion SDK credential instances
384+
All runtimes fetch a complete API-key/customer-ID bundle before use and use managed identity
385+
for vault access. With caching enabled, Soprano reuses managed-identity and client-assertion SDK credentials
370386
without Key Vault or a client-secret fallback. Evaluation skips credential resolution on the
371387
request path even when independent startup/refresh work runs.
372388

‎docs/ONBOARDING.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ can restore setup-managed values.
9797
| `KEY_VAULT_URL` | Summary's `resources.keyVault`; vault Overview > Vault URI. | Put Telesign credentials in this vault. Setup grants its Function system identity Key Vault Secrets User. |
9898
| `EPP_DECRYPTION_KEY_PEM`, `EPP_ENCRYPTION_KEY_ID` | Versioned Key Vault reference and registered encryption credential ID. Summary includes certificate/secret identifiers and expiry, **not** private-key bytes. | Do not view/copy the private key. Assign a [renewal owner](../setup/docs/README.md#encryption-certificate-lifecycle). |
9999
| `EPP_PROVIDER_TIMEOUT_MS`, `EPP_PROVIDER_RETRY_INTERVAL_MS` | Profile timing values. Runtime provider HTTP timeout is capped at 2500 ms. | Neither is a whole-request deadline; retry interval metadata does **not** enable send retries. |
100-
| `EPP_KEY_VAULT_CACHE_ENABLED`, `EPP_ACCESS_TOKEN_CACHE_ENABLED` | Setup writes `false` for FC1, `true` for EP1. | Current checked-in runtimes do not read these switches. Verify the selected release before assuming cache control; see [plan guidance](../setup/docs/README.md#service-plan-selection). |
100+
| `EPP_KEY_VAULT_CACHE_ENABLED`, `EPP_ACCESS_TOKEN_CACHE_ENABLED` | Setup writes `false` for FC1, `true` for EP1. | Independently control API-key/OAuth caching and startup preparation. Unset defaults to `true`; deploy a supporting package and restart after changes. See [plan guidance](../setup/docs/README.md#service-plan-selection). |
101101
| Application Insights, storage, runtime/package settings and identities | Created/configured for the selected plan; system identity handles vault/storage/telemetry, outbound identity handles Soprano exchange. | Verify telemetry ingestion. Do not copy local emulator settings or EP1-only settings into FC1. |
102102
| Inbound caller issuer, audience and allowlist | Function App > Authentication; setup configures Easy Auth for the Microsoft phone-provider caller. | Read back platform authentication, not just `EPP_EXPECTED_*` metadata. App settings are not an alternative caller-authentication gate. |
103103

@@ -212,8 +212,8 @@ this did not demonstrate the expected authentication gate. Transport/redirect er
212212
passes. This only checks the missing-token case, not all authorization or readiness properties.
213213

214214
Evaluation skips provider selection/credential lookup and provider HTTP **on its request path**.
215-
Configured workers can independently acquire credentials at startup; JavaScript/Python also
216-
poll for refresh. Do not confuse those background events with an evaluation sending a message.
215+
With the selected cache enabled, configured workers can independently acquire credentials at startup;
216+
JavaScript/Python also poll for refresh. Do not confuse those events with an evaluation sending a message.
217217

218218
For live requests, `200` with matching nonce means **provider acceptance, not delivery**.
219219
Soprano voice extracts the first six-digit sequence; Telesign voice paces standalone six-digit

‎dotnet/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ default method selects by `EPP_PROVIDER_NAME`; replace only its body if deployme
124124
tenant or other request-aware selection. No router or routing configuration abstraction is required.
125125

126126
`CredentialTokenService` is the hosted startup warmer and runtime credential cache.
127-
It asks the selected provider for credentials at startup and on cache misses.
127+
`EPP_KEY_VAULT_CACHE_ENABLED` controls API-key caching; `EPP_ACCESS_TOKEN_CACHE_ENABLED` controls
128+
OAuth caching. Both default to `true`. Setting the selected switch to `false` skips startup warmup
129+
and fetches on every live request, using fresh SDK credentials for OAuth.
130+
When enabled, it asks the selected provider for credentials at startup and on cache misses.
128131
Each provider owns credential acquisition and its secret names. The service stores
129132
the result in .NET `MemoryCache` until the credential's absolute expiry; the next request fetches a
130133
replacement. There is no polling timer or separate cache implementation. The fetch has a

‎dotnet/Src/AppConfig.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ public sealed class AppConfig
1414
public string? OutboundManagedIdentityClientId { get; init; }
1515
// Keep the raw value; SendOtp owns timeout normalization.
1616
public string? ProviderTimeoutMs { get; init; }
17+
// Validate cache switches only on credential paths; evaluation needs neither cache.
18+
public string? KeyVaultCacheEnabled { get; init; }
19+
public string? AccessTokenCacheEnabled { get; init; }
1720

1821
public static AppConfig Read(IEnv env) => new()
1922
{
@@ -28,5 +31,7 @@ public sealed class AppConfig
2831
OutboundClientId = env.Get("EPP_OUTBOUND_CLIENT_ID")?.Trim(),
2932
OutboundManagedIdentityClientId = env.Get("EPP_OUTBOUND_MI_CLIENT_ID")?.Trim(),
3033
ProviderTimeoutMs = env.Get("EPP_PROVIDER_TIMEOUT_MS"),
34+
KeyVaultCacheEnabled = env.Get("EPP_KEY_VAULT_CACHE_ENABLED"),
35+
AccessTokenCacheEnabled = env.Get("EPP_ACCESS_TOKEN_CACHE_ENABLED"),
3136
};
3237
}

‎dotnet/Src/CredentialTokenService.cs‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,12 @@ public async Task<ProviderCredentials> GetCredentialsAsync(
4141
ObjectDisposedException.ThrowIf(_disposed, this);
4242
try
4343
{
44+
if (!IsCacheEnabled(provider.AuthenticationMode, config))
45+
return await FetchAsync(provider, config, cancellationToken).ConfigureAwait(false);
46+
4447
var value = await _cache.GetOrCreateAsync(provider.Name, async entry =>
4548
{
46-
using var acquisition = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
47-
acquisition.CancelAfter(AcquisitionTimeout);
48-
var credentials = await provider.FetchCredentialsAsync(
49-
config, acquisition.Token).ConfigureAwait(false);
50-
if (credentials.ExpiresOn <= DateTimeOffset.UtcNow)
51-
throw Unavailable();
49+
var credentials = await FetchAsync(provider, config, cancellationToken).ConfigureAwait(false);
5250
entry.AbsoluteExpiration = credentials.ExpiresOn;
5351
return credentials;
5452
}).ConfigureAwait(false);
@@ -65,6 +63,33 @@ public async Task<ProviderCredentials> GetCredentialsAsync(
6563
}
6664
}
6765

66+
private static async Task<ProviderCredentials> FetchAsync(
67+
PhoneProviderBase provider, AppConfig config, CancellationToken cancellationToken)
68+
{
69+
using var acquisition = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
70+
acquisition.CancelAfter(AcquisitionTimeout);
71+
var credentials = await provider.FetchCredentialsAsync(config, acquisition.Token).ConfigureAwait(false);
72+
if (credentials.ExpiresOn <= DateTimeOffset.UtcNow)
73+
throw Unavailable();
74+
return credentials;
75+
}
76+
77+
internal static bool IsCacheEnabled(string authenticationMode, AppConfig config)
78+
{
79+
var setting = authenticationMode switch
80+
{
81+
"apiKey" => config.KeyVaultCacheEnabled,
82+
"oauth" => config.AccessTokenCacheEnabled,
83+
_ => throw Unavailable(),
84+
};
85+
return setting?.Trim().ToLowerInvariant() switch
86+
{
87+
null or "true" => true,
88+
"false" => false,
89+
_ => throw Unavailable(),
90+
};
91+
}
92+
6893
public async Task StartAsync(CancellationToken cancellationToken)
6994
{
7095
if (_env is null) return;
@@ -80,6 +105,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
80105
}
81106
try
82107
{
108+
if (!IsCacheEnabled(provider.AuthenticationMode, config)) return;
83109
await GetCredentialsAsync(provider, config, cancellationToken).ConfigureAwait(false);
84110
}
85111
catch

0 commit comments

Comments
 (0)