From 04352848fa1a009834ff7e28318c9c62f9799d98 Mon Sep 17 00:00:00 2001 From: James Xian Date: Thu, 8 Oct 2026 15:44:49 -0700 Subject: [PATCH] Control credential caches through environment variables Honor independent Key Vault and OAuth cache switches in the current credential services. Keep missing settings enabled, validate selected values lazily, and use request-scoped acquisition when disabled. Reuse existing deadlines and lifecycle handling while preserving .NET startup/cache-miss behavior without polling. Add focused regression coverage and update only cache-related documentation on current main. The merged setup infrastructure fix and obsolete runtime abstractions are not replayed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +- TECHNICAL.md | 15 ++- docs/CONTRACT.md | 36 ++++-- docs/ONBOARDING.md | 6 +- dotnet/README.md | 5 +- dotnet/Src/AppConfig.cs | 5 + dotnet/Src/CredentialTokenService.cs | 38 ++++++- dotnet/Src/Providers/SopranoProvider.cs | 31 +++--- dotnet/tests/CredentialTokenServiceTests.cs | 115 ++++++++++++++++---- dotnet/tests/SendOtpTests.cs | 53 +++++++-- javascript/README.md | 6 +- javascript/src/functions/SendOtp.js | 2 + javascript/src/functions/config.js | 3 + javascript/src/functions/credentials.js | 62 ++++++++--- javascript/test/credential-cache.test.js | 94 +++++++++++++++- javascript/test/credential-sdk.test.js | 35 ++++++ javascript/test/sendotp.test.js | 37 +++++++ python/README.md | 6 +- python/function_app.py | 4 +- python/src/config.py | 5 + python/src/credentials.py | 89 +++++++++++---- python/tests/test_credential_cache.py | 109 ++++++++++++++++++- python/tests/test_credential_sdk.py | 28 +++-- python/tests/test_function_app.py | 49 +++++++++ setup/docs/README.md | 10 +- setup/docs/Troubleshooting.md | 7 +- 26 files changed, 723 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 3376fb0..06ff873 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The single-region request flow is: The diagram's delivery path describes **live requests**. An authorized, valid encrypted **evaluation request (`mode: 2`)** returns the matching nonce without submitting a message to the -provider. Background credential refresh can still run independently. Authentication failures may +provider. With caching enabled, background credential refresh can still run independently. Authentication failures may return **401 or 403**; neither is a successful evaluation. **East US in the diagram is illustrative, not a required or guaranteed deployment location.** @@ -231,7 +231,8 @@ not just a locally running Function. | 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. | | Operational visibility | Review Application Insights for the request outcome without recording phone numbers, message bodies, tokens, private keys, or nonce values in shared logs. | -Configured workers can acquire credentials at startup without sending an OTP; JavaScript/Python +With the selected [credential cache](docs/CONTRACT.md#credential-caching-and-refresh) enabled, +configured workers can acquire credentials at startup without sending an OTP; JavaScript/Python also poll for refresh, whereas .NET retrieves replacements on cache misses. Check collected `credential_refresh_failed` warnings before live testing; an evaluation success or absence of warnings does not validate provider credentials. diff --git a/TECHNICAL.md b/TECHNICAL.md index 083023e..d9b6e71 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -178,6 +178,8 @@ how code accesses configuration, not the environment-variable names. | `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE` | Soprano OAuth | Provider tenant and selected API scope. | | `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. | | `EPP_PROVIDER_TIMEOUT_MS` | Optional | Decimal milliseconds. Defaults to `1500`, capped at `2500`; not an end-to-end deadline. | +| `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`. | +| `EPP_ACCESS_TOKEN_CACHE_ENABLED` | Optional | `true` enables OAuth credential caching; `false` uses request-scoped MI/client-assertion credentials. Unset defaults to `true`. | | `EPP_PROVIDER_ACCOUNT_NAME` | Adapter-dependent | Sender/account metadata, not an API key or credential identity. | | `KEY_VAULT_URL` | Provider credential lookup | URI of the vault containing the manifest-named provider secrets. Separate from the encryption-key reference. | | `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 evaluation locally, or an explicitly injected test resolver for integration work. Never commit local settings, keys or test credentials. -Configured providers are [prepared per worker](docs/CONTRACT.md#credential-caching-and-refresh). +When their selected cache is enabled, configured providers are +[prepared per worker](docs/CONTRACT.md#credential-caching-and-refresh). JavaScript/Python warm credentials and poll for refresh; .NET warms at startup and retrieves replacements on cache misses, without a periodic poller. Credential acquisition never sends an OTP. Evaluation skips provider work, but configured workers can independently acquire credentials at -startup. Leave `EPP_PROVIDER_NAME` unset for local evaluation-only work without credential acquisition. -The setup-written cache switches do not change current checked-in runtime behavior; verify your -selected package rather than assuming plan selection enables/disables caching. +startup. Disabling the selected cache skips startup preparation, polling, and cross-request reuse, +but live requests still acquire credentials. Each switch accepts trimmed, case-insensitive `true` or +`false`; an invalid selected value fails live credential resolution closed without preventing evaluation. +The other provider-auth mode's switch is ignored. Setup writes both as `false` for FC1 or `true` for +EP1; older packages that do not read these settings still require a supporting release. Restart after +changes. Leave `EPP_PROVIDER_NAME` unset or disable its cache for local evaluation-only work without +credential acquisition. Platform-managed identity caching and decryption-key references are separate. Core Tools does not resolve Azure Key Vault reference expressions locally. Supply the local test PEM or base64 PEM directly; use a reference such as `@Microsoft.KeyVault(SecretUri=https://.vault.azure.net/secrets//)` diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 939f6d1..2d5631c 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -125,7 +125,8 @@ there is no API-key fallback. Evaluation skips acquisition. A provider rejection Tokens are treated as opaque: the Function checks SDK expiry metadata, not custom JWT claims. Soprano remains responsible for signature, issuer, audience, expiry, permissions, and account validation. -Credential instances and their SDK caches are reused for the configured tenant/application/identity. +With `EPP_ACCESS_TOKEN_CACHE_ENABLED=true` (the default), credential instances and their SDK caches +are reused for the configured tenant/application/identity. JavaScript/Python use a [worker-local refresh loop](#credential-caching-and-refresh) for both exchange stages. .NET warms at startup and fetches a replacement on a cache miss; it has no poller. 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 shared synchronous retrieval may finish after a waiter leaves. It uses `get_token_info` for refresh hints when supported, otherwise `get_token`; a failed acquisition never falls back to another API. -Credential SDK transport retries are disabled. JavaScript/Python failed refreshes use the polling +Credential SDK transport retries are disabled. With caching enabled, JavaScript/Python failed refreshes use the polling cadence below; .NET retries credential acquisition on a later cache miss. These are not end-to-end delivery deadlines. JavaScript suppresses SDK logs in the 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 network access. Core Tools has no Easy Auth; local evaluation must remain loopback-only, without tunnels. This describes the evaluation **request path**. Independently, workers with a configured provider -automatically prewarm and refresh credentials, even if their current traffic is evaluation-only. +and its cache enabled prepare credentials at startup; JavaScript/Python also poll for refresh, +even if their current traffic is evaluation-only. No background task dispatches an OTP. A worker without `EPP_PROVIDER_NAME` performs no credential -prewarming, and evaluation does not require that prewarming succeed. +prewarming. Disabling the selected cache also suppresses this preparation; evaluation never requires it to succeed. There is no diagnostic environment flag. A live request is not an evaluation request. Adapter-specific 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.** | `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | client application and user-assigned identity used for Soprano client-assertion exchange | | `EPP_PROVIDER_ACCOUNT_NAME` | sender/source only when required by the selected provider | | `EPP_PROVIDER_TIMEOUT_MS` | trimmed ASCII decimal milliseconds; default 1500 for missing/invalid/nonpositive values; capped at 2500. Not a whole-invocation deadline | +| `EPP_KEY_VAULT_CACHE_ENABLED` | `true`/`false`: API-key bundle caching and startup/refresh; unset defaults to `true` | +| `EPP_ACCESS_TOKEN_CACHE_ENABLED` | `true`/`false`: OAuth credential caching and startup/refresh; unset defaults to `true` | | `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 | | `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 | | `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 Provider credentials are process-local, distinct from the platform-resolved decryption-key reference. Credential acquisition never sends an OTP or changes caller authentication. -Restart workers after configuration changes. The setup-written -`EPP_KEY_VAULT_CACHE_ENABLED` / `EPP_ACCESS_TOKEN_CACHE_ENABLED` switches are not read by the -current checked-in implementations; verify the selected release before relying on plan-specific -cache control. +All runtimes use `EPP_KEY_VAULT_CACHE_ENABLED` for the selected `apiKey` provider or +`EPP_ACCESS_TOKEN_CACHE_ENABLED` for the selected `oauth` provider. The switches are independent; +runtime selection does not depend on the hosting plan. Unset defaults to enabled. Values accept +trimmed, case-insensitive `true` or `false`; blank or other explicit selected values fail live +credential acquisition closed with a sanitized warning, without blocking evaluation. +Restart workers after configuration changes. Setup writes both as `false` for FC1 or `true` for EP1; +deploy a supporting package, since older releases do not read these settings. + +With caching **disabled**, each live request retrieves a complete Key Vault bundle or uses fresh +managed-identity/client-assertion SDK credentials for OAuth. There is no startup preparation, +periodic polling, cross-request credential sharing, or failure cooldown. Request-scoped state is +discarded after acquisition; Python closes its SDK clients when synchronous acquisition finishes. +The same expiry checks and acquisition budgets below still apply. Azure's managed-identity service +and platform Key Vault-reference caching remain outside these switches. + +With caching **enabled**, each runtime retains its existing policy: | Runtime | Startup and replacement behavior | Operator consequence | |---|---|---| @@ -365,8 +381,8 @@ transport. Python bounds waits and SDK connect/read inactivity to 2.5 seconds bu cancel synchronous I/O. .NET uses a 2.5-second fetch budget linked to the fetch caller's cancellation token. None is a whole-invocation deadline. -All runtimes fetch a complete API-key/customer-ID bundle before caching it and use managed identity -for vault access. Soprano reuses the managed-identity and client-assertion SDK credential instances +All runtimes fetch a complete API-key/customer-ID bundle before use and use managed identity +for vault access. With caching enabled, Soprano reuses managed-identity and client-assertion SDK credentials without Key Vault or a client-secret fallback. Evaluation skips credential resolution on the request path even when independent startup/refresh work runs. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index ee052df..c5cc6a6 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -97,7 +97,7 @@ can restore setup-managed values. | `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. | | `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). | | `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. | -| `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). | +| `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). | | 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. | | 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. | @@ -212,8 +212,8 @@ this did not demonstrate the expected authentication gate. Transport/redirect er passes. This only checks the missing-token case, not all authorization or readiness properties. Evaluation skips provider selection/credential lookup and provider HTTP **on its request path**. -Configured workers can independently acquire credentials at startup; JavaScript/Python also -poll for refresh. Do not confuse those background events with an evaluation sending a message. +With the selected cache enabled, configured workers can independently acquire credentials at startup; +JavaScript/Python also poll for refresh. Do not confuse those events with an evaluation sending a message. For live requests, `200` with matching nonce means **provider acceptance, not delivery**. Soprano voice extracts the first six-digit sequence; Telesign voice paces standalone six-digit diff --git a/dotnet/README.md b/dotnet/README.md index 0a53da4..3bd4ca2 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -124,7 +124,10 @@ default method selects by `EPP_PROVIDER_NAME`; replace only its body if deployme tenant or other request-aware selection. No router or routing configuration abstraction is required. `CredentialTokenService` is the hosted startup warmer and runtime credential cache. -It asks the selected provider for credentials at startup and on cache misses. +`EPP_KEY_VAULT_CACHE_ENABLED` controls API-key caching; `EPP_ACCESS_TOKEN_CACHE_ENABLED` controls +OAuth caching. Both default to `true`. Setting the selected switch to `false` skips startup warmup +and fetches on every live request, using fresh SDK credentials for OAuth. +When enabled, it asks the selected provider for credentials at startup and on cache misses. Each provider owns credential acquisition and its secret names. The service stores the result in .NET `MemoryCache` until the credential's absolute expiry; the next request fetches a replacement. There is no polling timer or separate cache implementation. The fetch has a diff --git a/dotnet/Src/AppConfig.cs b/dotnet/Src/AppConfig.cs index fddca29..65fa81f 100644 --- a/dotnet/Src/AppConfig.cs +++ b/dotnet/Src/AppConfig.cs @@ -14,6 +14,9 @@ public sealed class AppConfig public string? OutboundManagedIdentityClientId { get; init; } // Keep the raw value; SendOtp owns timeout normalization. public string? ProviderTimeoutMs { get; init; } + // Validate cache switches only on credential paths; evaluation needs neither cache. + public string? KeyVaultCacheEnabled { get; init; } + public string? AccessTokenCacheEnabled { get; init; } public static AppConfig Read(IEnv env) => new() { @@ -28,5 +31,7 @@ public sealed class AppConfig OutboundClientId = env.Get("EPP_OUTBOUND_CLIENT_ID")?.Trim(), OutboundManagedIdentityClientId = env.Get("EPP_OUTBOUND_MI_CLIENT_ID")?.Trim(), ProviderTimeoutMs = env.Get("EPP_PROVIDER_TIMEOUT_MS"), + KeyVaultCacheEnabled = env.Get("EPP_KEY_VAULT_CACHE_ENABLED"), + AccessTokenCacheEnabled = env.Get("EPP_ACCESS_TOKEN_CACHE_ENABLED"), }; } \ No newline at end of file diff --git a/dotnet/Src/CredentialTokenService.cs b/dotnet/Src/CredentialTokenService.cs index 21c2552..8a0de9a 100644 --- a/dotnet/Src/CredentialTokenService.cs +++ b/dotnet/Src/CredentialTokenService.cs @@ -41,14 +41,12 @@ public async Task GetCredentialsAsync( ObjectDisposedException.ThrowIf(_disposed, this); try { + if (!IsCacheEnabled(provider.AuthenticationMode, config)) + return await FetchAsync(provider, config, cancellationToken).ConfigureAwait(false); + var value = await _cache.GetOrCreateAsync(provider.Name, async entry => { - using var acquisition = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - acquisition.CancelAfter(AcquisitionTimeout); - var credentials = await provider.FetchCredentialsAsync( - config, acquisition.Token).ConfigureAwait(false); - if (credentials.ExpiresOn <= DateTimeOffset.UtcNow) - throw Unavailable(); + var credentials = await FetchAsync(provider, config, cancellationToken).ConfigureAwait(false); entry.AbsoluteExpiration = credentials.ExpiresOn; return credentials; }).ConfigureAwait(false); @@ -65,6 +63,33 @@ public async Task GetCredentialsAsync( } } + private static async Task FetchAsync( + PhoneProviderBase provider, AppConfig config, CancellationToken cancellationToken) + { + using var acquisition = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + acquisition.CancelAfter(AcquisitionTimeout); + var credentials = await provider.FetchCredentialsAsync(config, acquisition.Token).ConfigureAwait(false); + if (credentials.ExpiresOn <= DateTimeOffset.UtcNow) + throw Unavailable(); + return credentials; + } + + internal static bool IsCacheEnabled(string authenticationMode, AppConfig config) + { + var setting = authenticationMode switch + { + "apiKey" => config.KeyVaultCacheEnabled, + "oauth" => config.AccessTokenCacheEnabled, + _ => throw Unavailable(), + }; + return setting?.Trim().ToLowerInvariant() switch + { + null or "true" => true, + "false" => false, + _ => throw Unavailable(), + }; + } + public async Task StartAsync(CancellationToken cancellationToken) { if (_env is null) return; @@ -80,6 +105,7 @@ public async Task StartAsync(CancellationToken cancellationToken) } try { + if (!IsCacheEnabled(provider.AuthenticationMode, config)) return; await GetCredentialsAsync(provider, config, cancellationToken).ConfigureAwait(false); } catch diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index 5c040cf..7dde082 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -19,9 +19,7 @@ public sealed class SopranoProvider : PhoneProviderBase private readonly object _credentialGate = new(); private readonly Func _createIdentity; private readonly Func>, TokenCredential> _createCredential; - private TokenCredential? _identity; - private TokenCredential? _credential; - private string? _scope; + private (TokenCredential Identity, TokenCredential Credential, string Scope)? _credentials; public SopranoProvider() : this( @@ -115,10 +113,10 @@ private static ProviderResult MapResponse(ResponseBody? responseBody, HttpStatus public override async Task FetchCredentialsAsync( AppConfig config, CancellationToken cancellationToken = default) { - ConfigureCredentials(config); - await GetAssertionAsync(cancellationToken).ConfigureAwait(false); - var token = CheckToken(await _credential!.GetTokenAsync( - new TokenRequestContext([_scope!]), + var (identity, credential, scope) = ConfigureCredentials(config); + await GetAssertionAsync(identity, cancellationToken).ConfigureAwait(false); + var token = CheckToken(await credential.GetTokenAsync( + new TokenRequestContext([scope]), cancellationToken).ConfigureAwait(false)); return new ProviderCredentials( AuthenticationMode, @@ -126,27 +124,30 @@ public override async Task FetchCredentialsAsync( ExpiresOn: token.ExpiresOn - ExpirySkew); } - private void ConfigureCredentials(AppConfig config) + private (TokenCredential Identity, TokenCredential Credential, string Scope) ConfigureCredentials(AppConfig config) { + var cacheEnabled = CredentialTokenService.IsCacheEnabled(AuthenticationMode, config); lock (_credentialGate) { - if (_credential is not null) return; + if (cacheEnabled && _credentials is { } cached) return cached; if (string.IsNullOrWhiteSpace(config.ProviderTenantId) || string.IsNullOrWhiteSpace(config.ProviderScope) || string.IsNullOrWhiteSpace(config.OutboundClientId) || string.IsNullOrWhiteSpace(config.OutboundManagedIdentityClientId)) throw CredentialTokenService.Unavailable(); - _scope = config.ProviderScope; - _identity = _createIdentity(config.OutboundManagedIdentityClientId); - _credential = _createCredential( + var identity = _createIdentity(config.OutboundManagedIdentityClientId); + var credential = _createCredential( config.ProviderTenantId, config.OutboundClientId, - async cancellation => (await GetAssertionAsync(cancellation).ConfigureAwait(false)).Token); + async cancellation => (await GetAssertionAsync(identity, cancellation).ConfigureAwait(false)).Token); + var configured = (identity, credential, config.ProviderScope); + if (cacheEnabled) _credentials = configured; + return configured; } } - private async Task GetAssertionAsync(CancellationToken cancellationToken) => - CheckToken(await _identity!.GetTokenAsync( + private static async Task GetAssertionAsync(TokenCredential identity, CancellationToken cancellationToken) => + CheckToken(await identity.GetTokenAsync( new TokenRequestContext(["api://AzureADTokenExchange/.default"]), cancellationToken).ConfigureAwait(false)); diff --git a/dotnet/tests/CredentialTokenServiceTests.cs b/dotnet/tests/CredentialTokenServiceTests.cs index 1d1bd93..b7ca801 100644 --- a/dotnet/tests/CredentialTokenServiceTests.cs +++ b/dotnet/tests/CredentialTokenServiceTests.cs @@ -8,8 +8,10 @@ namespace Epp.Otp.Tests; public class CredentialTokenServiceTests { - [Fact] - public async Task CachedCredentialsAreReusedUntilExpiration() + [Theory] + [InlineData(null)] + [InlineData(" TrUe ")] + public async Task CachedCredentialsAreReusedUntilExpiration(string? setting) { var calls = 0; using var service = CreateService(); @@ -19,12 +21,61 @@ Task Fetch(CancellationToken cancellation) return Task.FromResult(ApiKey("value")); } var provider = new TestProvider("provider", Fetch); + var config = new AppConfig { KeyVaultCacheEnabled = setting, AccessTokenCacheEnabled = "PRIVATE-UNUSED" }; - Assert.Equal("value", (await service.GetCredentialsAsync(provider, new AppConfig())).Secret); - Assert.Equal("value", (await service.GetCredentialsAsync(provider, new AppConfig())).Secret); + Assert.Equal("value", (await service.GetCredentialsAsync(provider, config)).Secret); + Assert.Equal("value", (await service.GetCredentialsAsync(provider, config)).Secret); Assert.Equal(1, calls); } + [Fact] + public async Task DisabledCacheFetchesEveryTimeAndRetriesAfterFailureOrExpiry() + { + var calls = 0; + using var service = CreateService(); + var provider = new TestProvider("provider", _ => Task.FromResult(++calls switch + { + 3 => throw new InvalidOperationException("PRIVATE-FAILURE"), + 4 => ApiKey("expired", TimeSpan.FromSeconds(-1)), + _ => ApiKey("value-" + calls), + })); + var config = new AppConfig { KeyVaultCacheEnabled = " FaLsE ", AccessTokenCacheEnabled = "PRIVATE-UNUSED" }; + Assert.Equal("value-1", (await service.GetCredentialsAsync(provider, config)).Secret); + Assert.Equal("value-2", (await service.GetCredentialsAsync(provider, config)).Secret); + await Assert.ThrowsAsync(() => service.GetCredentialsAsync(provider, config)); + await Assert.ThrowsAsync(() => service.GetCredentialsAsync(provider, config)); + Assert.Equal("value-5", (await service.GetCredentialsAsync(provider, config)).Secret); + } + + [Theory] + [InlineData("")] + [InlineData("1")] + [InlineData("yes")] + [InlineData("PRIVATE-INVALID")] + public async Task InvalidCacheSwitchFailsBeforeAcquisitionWithSanitizedLogging(string setting) + { + foreach (var mode in new[] { "apiKey", "oauth" }) + { + var calls = 0; + var logger = new CredentialLogger(); + using var service = new CredentialTokenService(log: logger); + var provider = new TestProvider("provider", _ => + { + calls++; + return Task.FromResult(ApiKey("unused")); + }, mode); + var config = new AppConfig { KeyVaultCacheEnabled = setting, AccessTokenCacheEnabled = setting }; + var error = await Assert.ThrowsAsync(() => + service.GetCredentialsAsync(provider, config)); + Assert.Equal("provider credential unavailable", error.Message); + Assert.Equal(0, calls); + var entry = Assert.Single(logger.Entries); + Assert.Equal("credential_refresh_failed", entry.EventId.Name); + Assert.Null(entry.Error); + Assert.DoesNotContain("PRIVATE", entry.Message); + } + } + [Fact] public async Task MemoryCacheExpiresCredentialsAndFetchesAReplacement() { @@ -40,8 +91,10 @@ Task Fetch(CancellationToken cancellation) => Assert.Equal("value-2", (await service.GetCredentialsAsync(provider, new AppConfig())).Secret); } - [Fact] - public async Task CallerCancellationCancelsItsCredentialFetch() + [Theory] + [InlineData(null)] + [InlineData("false")] + public async Task CallerCancellationCancelsItsCredentialFetch(string? setting) { CancellationToken observed = default; using var service = CreateService(); @@ -54,35 +107,45 @@ async Task Fetch(CancellationToken cancellation) var provider = new TestProvider("provider", Fetch); using var waiter = new CancellationTokenSource(); - var pending = service.GetCredentialsAsync(provider, new AppConfig(), waiter.Token); + var pending = service.GetCredentialsAsync(provider, new AppConfig { KeyVaultCacheEnabled = setting }, waiter.Token); waiter.Cancel(); await Assert.ThrowsAnyAsync(() => pending); Assert.True(observed.IsCancellationRequested); } - [Fact] - public async Task DisposalPreventsFurtherUse() + [Theory] + [InlineData(null)] + [InlineData("false")] + public async Task DisposalPreventsFurtherUse(string? setting) { using var service = CreateService(); service.Dispose(); var provider = new TestProvider("provider", _ => Task.FromResult(ApiKey("unused"))); await Assert.ThrowsAsync(() => - service.GetCredentialsAsync(provider, new AppConfig())); + service.GetCredentialsAsync(provider, new AppConfig { KeyVaultCacheEnabled = setting })); } - [Fact] - public async Task OAuthUsesManagedIdentityAssertionAndCachesTheProviderToken() + [Theory] + [InlineData(null, 1)] + [InlineData(" TrUe ", 1)] + [InlineData(" FaLsE ", 2)] + public async Task OAuthScopesSdkCredentialsAccordingToCacheSetting(string? setting, int acquisitions) { var identityCalls = 0; var providerCalls = 0; + var identityInstances = 0; var credentialInstances = 0; var provider = new SopranoProvider( - _ => new Token(async (_, _) => + _ => { - Interlocked.Increment(ref identityCalls); - await Task.Yield(); - return new("PRIVATE-ASSERTION", DateTimeOffset.UtcNow.AddHours(1)); - }), + identityInstances++; + return new Token(async (_, _) => + { + Interlocked.Increment(ref identityCalls); + await Task.Yield(); + return new("PRIVATE-ASSERTION", DateTimeOffset.UtcNow.AddHours(1)); + }); + }, (_, _, assertion) => { credentialInstances++; @@ -94,14 +157,15 @@ public async Task OAuthUsesManagedIdentityAssertionAndCachesTheProviderToken() }); }); using var service = CreateService(); - var config = OAuthConfig(); + var config = OAuthConfig(cacheEnabled: setting); var initial = await service.GetCredentialsAsync(provider, config); Assert.Equal("PRIVATE-PROVIDER", initial.AccessToken); await service.GetCredentialsAsync(provider, config); - Assert.Equal(2, identityCalls); - Assert.Equal(1, providerCalls); - Assert.Equal(1, credentialInstances); + Assert.Equal(acquisitions * 2, identityCalls); + Assert.Equal(acquisitions, providerCalls); + Assert.Equal(acquisitions, identityInstances); + Assert.Equal(acquisitions, credentialInstances); } [Fact] @@ -153,20 +217,23 @@ private static ProviderCredentials ApiKey(string value, TimeSpan? lifetime = nul Secret: value, ExpiresOn: DateTimeOffset.UtcNow + (lifetime ?? TimeSpan.FromMinutes(5))); - private static AppConfig OAuthConfig(string scope = "api://provider/.default") => new() + private static AppConfig OAuthConfig(string scope = "api://provider/.default", string? cacheEnabled = null) => new() { ProviderTenantId = "tenant", ProviderScope = scope, OutboundClientId = "application", OutboundManagedIdentityClientId = "identity", + AccessTokenCacheEnabled = cacheEnabled, + KeyVaultCacheEnabled = "PRIVATE-UNUSED", }; private sealed class TestProvider( string name, - Func> fetch) : PhoneProviderBase + Func> fetch, + string authenticationMode = "apiKey") : PhoneProviderBase { public override string Name => name; - public override string AuthenticationMode => "test"; + public override string AuthenticationMode => authenticationMode; public override Task FetchCredentialsAsync( AppConfig config, CancellationToken cancellationToken = default) => diff --git a/dotnet/tests/SendOtpTests.cs b/dotnet/tests/SendOtpTests.cs index eaaff27..ef52ab8 100644 --- a/dotnet/tests/SendOtpTests.cs +++ b/dotnet/tests/SendOtpTests.cs @@ -45,19 +45,52 @@ private static void ConfigureSoprano(HandlerRig rig) rig.Http.Respond = _ => Task.FromResult(Json(201, "{\"status\":\"ENROUTE\"}")); } - [Fact] - public async Task StartupPreparesOnlyCredentialsAndWarmRequestsReuseTheBundle() + [Theory] + [InlineData(null, 1)] + [InlineData("true", 1)] + [InlineData("false", 0)] + public async Task StartupAndLiveRequestsRespectTheSelectedCache(string? setting, int warmCalls) { using var rig = new HandlerRig(); + if (setting is not null) rig.Env["EPP_KEY_VAULT_CACHE_ENABLED"] = setting; + rig.Env["EPP_ACCESS_TOKEN_CACHE_ENABLED"] = "PRIVATE-UNUSED"; await rig.Credentials.StartAsync(default); - Assert.Equal(1, rig.Secrets.Calls); + Assert.Equal(warmCalls, rig.Secrets.Calls); Assert.Equal(0, rig.Http.Calls); AssertAccepted(await rig.Invoke("evaluation")); - Assert.Equal(1, rig.Secrets.Calls); + Assert.Equal(warmCalls, rig.Secrets.Calls); Assert.Equal(0, rig.Http.Calls); AssertAccepted(await rig.Invoke()); - Assert.Equal(1, rig.Secrets.Calls); - Assert.Equal(1, rig.Http.Calls); + AssertAccepted(await rig.Invoke()); + Assert.Equal(warmCalls == 0 ? 2 : 1, rig.Secrets.Calls); + Assert.Equal(2, rig.Http.Calls); + } + + [Theory] + [InlineData("false", 200)] + [InlineData("PRIVATE-INVALID", 502)] + public async Task OAuthCacheSettingSkipsStartupAndNeverBlocksEvaluation(string setting, int liveStatus) + { + var calls = 0; + using var rig = new HandlerRig( + _ => new TestTokenCredential((_, _) => + ValueTask.FromResult(new AccessToken("assertion", DateTimeOffset.UtcNow.AddHours(1)))), + (_, _, _) => new TestTokenCredential((_, _) => + { + calls++; + return ValueTask.FromResult(new AccessToken("token", DateTimeOffset.UtcNow.AddHours(1))); + })); + ConfigureSoprano(rig); + rig.Env["EPP_ACCESS_TOKEN_CACHE_ENABLED"] = setting; + rig.Env["EPP_KEY_VAULT_CACHE_ENABLED"] = "PRIVATE-UNUSED"; + await rig.Credentials.StartAsync(default); + AssertAccepted(await rig.Invoke("evaluation")); + Assert.Equal(0, calls); + Assert.Equal(0, rig.Secrets.Calls); + Assert.Equal(0, rig.Http.Calls); + for (var i = 0; i < 2; i++) Assert.Equal(liveStatus, (await rig.Invoke()).StatusCode); + Assert.Equal(liveStatus == 200 ? 2 : 0, calls); + Assert.DoesNotContain("PRIVATE", string.Join("\n", rig.Log.Messages)); } [Fact] @@ -161,8 +194,10 @@ public async Task SopranoOAuthRejectsUnusableTokensBeforeProviderIo(bool invalid Assert.Equal((0, 0), (rig.Http.Calls, rig.Secrets.Calls)); } - [Fact] - public async Task SopranoOAuthCancellationAndRejectionNeverFallBackOrRetry() + [Theory] + [InlineData("true")] + [InlineData("false")] + public async Task SopranoOAuthCancellationAndRejectionNeverFallBackOrRetry(string setting) { CancellationToken observed = default; var waitForCancellation = true; @@ -183,6 +218,7 @@ TokenCredential CreateProvider(string tenant, string application, Func Task.FromResult(Json(401, "{\"status\":\"REJECTED\"}")); AssertFailure(replacement, await replacement.Invoke(), 401); Assert.Equal((1, 0), (replacement.Http.Calls, replacement.Secrets.Calls)); diff --git a/javascript/README.md b/javascript/README.md index 5808c3c..113118e 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -116,7 +116,11 @@ For deployed diagnostics, use [Application Insights](../docs/APPLICATION-INSIGHT [JavaScript service-event queries](../docs/MONITORING.md#4-javascript-service-event-queries). Logs are JSON `service` events ending in `request_completed`, not a `logType: "request"` summary. -The app-start hook selects `ApiKeyCache` or `AccessTokenCache` from the provider credential spec. +`EPP_KEY_VAULT_CACHE_ENABLED` controls API-key caching; `EPP_ACCESS_TOKEN_CACHE_ENABLED` controls +OAuth caching. Both default to `true`. Setting the selected switch to `false` acquires credentials +per live request, with no startup preparation, polling, or cross-request reuse. + +With caching enabled, the app-start hook selects `ApiKeyCache` or `AccessTokenCache` from the provider credential spec. Only that cache starts: API keys use Key Vault and `lru-cache`; access tokens use the MI/Entra SDKs, without Key Vault. One shared 30-second refresh loop and one in-flight acquisition keep warm reads nonblocking. Configuration changes require restart; failures never extend expiry. A small HTTP-client diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index e68f82b..b6b7983 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -15,6 +15,7 @@ const { selectProvider } = require('./providers'); const { credentialTokenService, reportRefreshFailure, + isCacheEnabled, } = require('./credentials'); const { ProviderTransportError, @@ -44,6 +45,7 @@ async function startProviderCredentialRefresh() { return; } try { + if (!isCacheEnabled(provider.credentialSpec, config)) return; await credentialTokenService.getCredentials(provider.credentialSpec, config); } catch { if (!credentialTokenService.current) reportRefreshFailure('configuration'); diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js index 90c139e..8d794f9 100644 --- a/javascript/src/functions/config.js +++ b/javascript/src/functions/config.js @@ -19,6 +19,9 @@ class AppConfig { this.outboundClientId = (env.EPP_OUTBOUND_CLIENT_ID || '').trim(); this.outboundManagedIdentityClientId = (env.EPP_OUTBOUND_MI_CLIENT_ID || '').trim(); this.providerTimeoutMs = env.EPP_PROVIDER_TIMEOUT_MS || ''; + // Validate cache switches only on credential paths; evaluation needs neither cache. + this.keyVaultCacheEnabled = env.EPP_KEY_VAULT_CACHE_ENABLED; + this.accessTokenCacheEnabled = env.EPP_ACCESS_TOKEN_CACHE_ENABLED; this.keyVaultUrl = (env.KEY_VAULT_URL || '').trim(); this.managedIdentityClientId = (env.AZURE_CLIENT_ID || '').trim(); this.env = env; diff --git a/javascript/src/functions/credentials.js b/javascript/src/functions/credentials.js index 530702a..f5d3a7f 100644 --- a/javascript/src/functions/credentials.js +++ b/javascript/src/functions/credentials.js @@ -71,6 +71,17 @@ function checkToken(token, now) { * @typedef {{now?: () => number, schedule?: typeof setInterval, cancel?: typeof clearInterval}} RefreshOptions */ +/** @param {AuthConfig} auth @param {AppConfig} config */ +function isCacheEnabled(auth, config) { + const mode = auth.mode || API_KEY_MODE; + if (mode !== API_KEY_MODE && mode !== OAUTH_MODE) throw unavailable(); + const setting = mode === API_KEY_MODE ? config.keyVaultCacheEnabled : config.accessTokenCacheEnabled; + if (setting == null) return true; + const value = setting.trim().toLowerCase(); + if (value !== 'true' && value !== 'false') throw unavailable(); + return value === 'true'; +} + class ApiKeyCache { /** @param {AuthConfig} auth @param {AppConfig} config */ constructor(auth, config, now = Date.now) { @@ -160,7 +171,7 @@ class AccessTokenCache { toJSON() { return '[AccessTokenCache]'; } } -// Owns one selected cache and one periodic refresh; configuration changes require a worker restart. +// Disabled caches use request-scoped acquisition without starting the worker's refresh loop. class CredentialTokenService { /** @param {{cacheOptions?: RefreshOptions, reportFailure?: (kind: string) => void}} [options] */ constructor({ cacheOptions = {}, reportFailure = reportRefreshFailure } = {}) { @@ -174,21 +185,32 @@ class CredentialTokenService { this.pending = null; /** @type {ReturnType | null} */ this.timer = null; - /** @type {AbortController | null} */ - this.controller = null; + /** @type {Set} */ + this.controllers = new Set(); this.nextAttemptAt = 0; this.closed = false; } /** @param {AuthConfig} auth @param {AppConfig} config */ async getCredentials(auth, config) { if (this.closed) throw unavailable(); + let enabled; + let cache; + try { + enabled = isCacheEnabled(auth, config); + cache = enabled && this.current ? this.current : this.createCache(auth, config); + } + catch { this.reportFailure('configuration'); throw unavailable(); } + if (!enabled) { + try { + await this.acquire(cache); + const value = cache.get(); + if (!value || this.closed) throw unavailable(); + return value; + } finally { cache.stop(); } + } if (!this.current) { try { - switch (auth.mode || API_KEY_MODE) { - case API_KEY_MODE: this.current = new ApiKeyCache(auth, config, this.now); break; - case OAUTH_MODE: this.current = new AccessTokenCache(config, this.now); break; - default: throw unavailable(); - } + this.current = cache; this.timer = this.schedule(() => { void this.refresh().catch(() => {}); }, REFRESH_POLL_MS); this.timer.unref?.(); } catch { this.reportFailure('configuration'); throw unavailable(); } @@ -201,13 +223,24 @@ class CredentialTokenService { return value; } resolve(auth, config) { return this.getCredentials(auth, config); } + /** @param {AuthConfig} auth @param {AppConfig} config */ + createCache(auth, config) { + return (auth.mode || API_KEY_MODE) === API_KEY_MODE + ? new ApiKeyCache(auth, config, this.now) + : new AccessTokenCache(config, this.now); + } refresh() { if (this.closed || !this.current) return Promise.reject(unavailable()); if (this.pending) return this.pending; if (this.nextAttemptAt > this.now()) return Promise.reject(unavailable()); this.nextAttemptAt = this.now() + REFRESH_POLL_MS; - const cache = this.current; - const controller = this.controller = new AbortController(); + this.pending = this.acquire(this.current).finally(() => { this.pending = null; }); + return this.pending; + } + /** @param {ApiKeyCache | AccessTokenCache} cache */ + acquire(cache) { + const controller = new AbortController(); + this.controllers.add(controller); if (AzureLogger.log !== filteredLogger) { const previous = AzureLogger.log; filteredLogger = (...args) => { if (!acquisition.getStore()) previous(...args); }; @@ -216,7 +249,7 @@ class CredentialTokenService { const timeout = setTimeout(() => controller.abort(), ACQUISITION_TIMEOUT_MS); const interrupted = new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(unavailable()), { once: true })); - this.pending = acquisition.run(controller.signal, () => Promise.race([ + return acquisition.run(controller.signal, () => Promise.race([ Promise.resolve().then(() => { controller.signal.throwIfAborted(); return cache.refresh(controller.signal); }), interrupted, ])).catch(() => { controller.abort(); @@ -224,15 +257,13 @@ class CredentialTokenService { throw unavailable(); }).finally(() => { clearTimeout(timeout); - this.pending = null; - this.controller = null; + this.controllers.delete(controller); }); - return this.pending; } close() { this.closed = true; if (this.timer) this.cancel(this.timer); - this.controller?.abort(); + for (const controller of this.controllers) controller.abort(); this.current?.stop(); } [inspect.custom]() { return '[CredentialTokenService]'; } @@ -250,4 +281,5 @@ module.exports = { ProviderCredentials, providerCredentials, reportRefreshFailure, + isCacheEnabled, }; diff --git a/javascript/test/credential-cache.test.js b/javascript/test/credential-cache.test.js index 08b2bf3..abd4571 100644 --- a/javascript/test/credential-cache.test.js +++ b/javascript/test/credential-cache.test.js @@ -3,7 +3,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { inspect } = require('node:util'); -const { ApiKeyCache, AccessTokenCache, CredentialTokenService } = require('../src/functions/credentials'); +const { ApiKeyCache, AccessTokenCache, CredentialTokenService, isCacheEnabled } = require('../src/functions/credentials'); const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('../src/functions/config'); @@ -43,6 +43,94 @@ function clock() { }; } +test('cache switches default to enabled and independently accept only trimmed true or false', () => { + for (const [value, expected] of [[undefined, true], ['true', true], [' TRUE ', true], ['false', false], [' FaLsE ', false]]) { + const settings = readConfig({ EPP_KEY_VAULT_CACHE_ENABLED: value, EPP_ACCESS_TOKEN_CACHE_ENABLED: value }); + assert.equal(isCacheEnabled(auth, settings), expected); + assert.equal(isCacheEnabled({ mode: 'oauth' }, settings), expected); + } + const settings = readConfig({ EPP_KEY_VAULT_CACHE_ENABLED: 'false', EPP_ACCESS_TOKEN_CACHE_ENABLED: 'true' }); + assert.equal(isCacheEnabled(auth, settings), false); + assert.equal(isCacheEnabled({ mode: 'oauth' }, settings), true); +}); + +test('invalid selected cache switches fail before acquisition or scheduling with sanitized errors', async (t) => { + const time = clock(); + const vault = t.mock.method(SecretClient.prototype, 'getSecret', () => assert.fail('Unexpected Key Vault')); + const token = t.mock.method(ClientAssertionCredential.prototype, 'getToken', () => assert.fail('Unexpected OAuth')); + for (const mode of ['apiKey', 'oauth']) { + for (const value of ['', '1', '0', 'yes', 'PRIVATE-INVALID']) { + const failures = []; + const manager = new CredentialTokenService({ cacheOptions: time.options, reportFailure: (kind) => failures.push(kind) }); + try { + await assert.rejects(manager.getCredentials({ ...auth, mode }, { + ...config, ...oauth, keyVaultCacheEnabled: value, accessTokenCacheEnabled: value, + }), /^Error: provider credential unavailable$/); + assert.deepEqual(failures, ['configuration']); + assert.equal(manager.current, null); + assert.equal(time.timerCount, 0); + } finally { manager.close(); } + } + } + assert.equal(vault.mock.callCount(), 0); + assert.equal(token.mock.callCount(), 0); +}); + +test('disabled Key Vault cache reads each bundle without polling, stale fallback, or failure cooldown', async (t) => { + const time = clock(); + let version = 1; + let fail = false; + const vault = t.mock.method(SecretClient.prototype, 'getSecret', async (name) => { + if (fail && name === 'id') throw new Error('PRIVATE-FAILURE'); + return { value: `${name}-${version}` }; + }); + const manager = new CredentialTokenService({ cacheOptions: time.options, reportFailure() {} }); + const settings = { ...config, keyVaultCacheEnabled: 'false', accessTokenCacheEnabled: 'PRIVATE-UNUSED' }; + try { + assert.equal((await manager.getCredentials(auth, settings)).secret, 'key-1'); + version = 2; + assert.deepEqual(await manager.getCredentials(auth, settings), { mode: 'apiKey', secret: 'key-2', identity: 'id-2' }); + fail = true; + await assert.rejects(manager.getCredentials(auth, settings), /unavailable/); + fail = false; + version = 3; + assert.equal((await manager.getCredentials(auth, settings)).identity, 'id-3'); + assert.equal(vault.mock.callCount(), 8); + assert.equal(manager.current, null); + assert.equal(manager.pending, null); + assert.equal(manager.controllers.size, 0); + assert.equal(time.timerCount, 0); + await time.advance(300000); + assert.equal(vault.mock.callCount(), 8); + } finally { manager.close(); } +}); + +test('disabled concurrent acquisitions are independent and all stop at shutdown', async (t) => { + const time = clock(); + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const vault = t.mock.method(SecretClient.prototype, 'getSecret', async () => { + await gate; + return { value: 'PRIVATE-LATE-KEY' }; + }); + const manager = new CredentialTokenService({ cacheOptions: time.options }); + const settings = { ...config, keyVaultCacheEnabled: 'false' }; + try { + const pending = Array.from({ length: 3 }, () => manager.getCredentials(auth, settings)); + const rejected = pending.map((request) => assert.rejects(request, /unavailable/)); + await flush(); + assert.equal(vault.mock.callCount(), 6); + assert.equal(time.timerCount, 0); + manager.close(); + await Promise.all(rejected); + release(); + await flush(); + assert.equal(manager.current, null); + assert.equal(manager.controllers.size, 0); + await assert.rejects(manager.getCredentials(auth, settings), /unavailable/); + } finally { release(); manager.close(); } +}); + test('library-backed bundle shares concurrent reads, serves during refresh, and publishes pairs atomically', async (t) => { const time = clock(); let release; @@ -110,12 +198,12 @@ test('partial refresh failure retains the old pair only until hard expiry, with test('invalid or disabled secret values are never published', async (t) => { const time = clock(); const getSecret = t.mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'old' })); - for (const invalid of [{ value: '' }, { value: 'bad', properties: { enabled: false } }, + for (const keyVaultCacheEnabled of [undefined, 'false']) for (const invalid of [{ value: '' }, { value: 'bad', properties: { enabled: false } }, { value: 'bad', properties: { notBefore: new Date(time.now + 3600000) } }, { value: 'bad', properties: { expiresOn: new Date(time.now) } }]) { const manager = new CredentialTokenService({ cacheOptions: time.options, reportFailure() {} }); getSecret.mock.mockImplementation(async () => invalid); - await assert.rejects(manager.resolve(auth, config), /unavailable/); + await assert.rejects(manager.resolve(auth, { ...config, keyVaultCacheEnabled }), /unavailable/); manager.close(); } }); diff --git a/javascript/test/credential-sdk.test.js b/javascript/test/credential-sdk.test.js index 1508872..28a90c7 100644 --- a/javascript/test/credential-sdk.test.js +++ b/javascript/test/credential-sdk.test.js @@ -119,6 +119,41 @@ test('real SDK sees one initial Entra exchange and none on concurrent or later w } finally { manager.close(); } }); +test('disabled token caching performs a new Entra exchange for each request through the real SDK', async () => { + const manager = new CredentialTokenService(); + const settings = config(); + settings.accessTokenCacheEnabled = 'false'; + settings.keyVaultCacheEnabled = 'PRIVATE-UNUSED'; + try { + for (let i = 0; i < 2; i++) { + assert.equal((await manager.resolve({ mode: 'oauth' }, settings)).accessToken, 'PRIVATE-TOKEN'); + } + assert.equal(state.tokenCalls, 2); + assert.equal(state.vaultCalls, 0); + assert.equal(manager.current, null); + assert.equal(manager.timer, null); + } finally { manager.close(); } +}); + +test('disabled token caching retains the SDK transport deadline without imposing a retry cooldown', async () => { + const failures = []; + const manager = new CredentialTokenService({ reportFailure: (kind) => failures.push(kind) }); + const settings = config(); + settings.accessTokenCacheEnabled = 'false'; + state.wait = 10000; + try { + await assert.rejects(manager.resolve({ mode: 'oauth' }, settings), /^Error: provider credential unavailable$/); + await new Promise(setImmediate); + assert.ok(state.requests.some((request) => !request.managedIdentity && request.aborted)); + assert.deepEqual(failures, ['provider_token']); + state.wait = 5; + assert.equal((await manager.resolve({ mode: 'oauth' }, settings)).accessToken, 'PRIVATE-TOKEN'); + assert.equal(state.tokenCalls, 2); + assert.equal(manager.current, null); + assert.equal(manager.timer, null); + } finally { manager.close(); } +}); + test('the cache-owned acquisition budget aborts actual SDK transport and does not cache a late token', async () => { const failures = []; const manager = new CredentialTokenService({ reportFailure: (kind) => failures.push(kind) }); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 68fbe6f..a61031f 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -44,6 +44,7 @@ const envKeys = [ 'EPP_PROVIDER_AUTH_MODE', 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_SCOPE', 'EPP_OUTBOUND_CLIENT_ID', 'EPP_OUTBOUND_MI_CLIENT_ID', 'KEY_VAULT_URL', 'EPP_DECRYPTION_KEY_PEM', 'SINCH_SERVICE_PLAN_ID', + 'EPP_KEY_VAULT_CACHE_ENABLED', 'EPP_ACCESS_TOKEN_CACHE_ENABLED', ]; const baseDelivery = { nonce: 'PRIVATE-NONCE', @@ -159,6 +160,42 @@ function event(name) { return records.find((record) => record.eventName === name); } +for (const provider of ['telesign', 'soprano']) { + test(`${provider}: disabled cache skips startup and evaluation but acquires for every live request`, async () => { + const apiKey = provider === 'telesign'; + process.env.EPP_PROVIDER_NAME = provider; + process.env.EPP_PROVIDER_AUTH_MODE = apiKey ? 'apiKey' : 'oauth'; + process.env[apiKey ? 'EPP_KEY_VAULT_CACHE_ENABLED' : 'EPP_ACCESS_TOKEN_CACHE_ENABLED'] = 'false'; + process.env[apiKey ? 'EPP_ACCESS_TOKEN_CACHE_ENABLED' : 'EPP_KEY_VAULT_CACHE_ENABLED'] = 'PRIVATE-UNUSED'; + if (apiKey) fetchMock.mock.mockImplementation(async () => ({ + ok: true, status: 200, text: async () => JSON.stringify({ status: { code: 3001 } }), + })); + await startHook(); + const evaluated = await invoke(await envelope({ mode: 2 })); + assert.equal(evaluated.status, 200); + assert.equal(evaluated.jsonBody.nonce, baseDelivery.nonce); + assert.deepEqual([getSecret.mock.callCount(), getToken.mock.callCount(), fetchMock.mock.callCount()], [0, 0, 0]); + for (let i = 0; i < 2; i++) assert.equal((await invoke(await envelope())).status, 200); + assert.equal(getSecret.mock.callCount(), apiKey ? 4 : 0); + assert.equal(getToken.mock.callCount(), apiKey ? 0 : 2); + assert.equal(service.current, null); + assert.equal(service.timer, null); + }); +} + +test('invalid cache settings fail live credentials but leave evaluation and private logs unchanged', async () => { + const warnings = mock.method(console, 'warn', () => {}); + process.env.EPP_KEY_VAULT_CACHE_ENABLED = 'PRIVATE-INVALID'; + process.env.EPP_ACCESS_TOKEN_CACHE_ENABLED = 'PRIVATE-INVALID'; + await startHook(); + assert.equal((await invoke(await envelope({ mode: 2 }))).status, 200); + failure(await invoke(await envelope()), 502); + assert.equal(event('request_failed').failureReason, 'credential_unavailable'); + assert.deepEqual([getToken.mock.callCount(), getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0, 0]); + assert.ok(warnings.mock.callCount() > 0); + assert.doesNotMatch(JSON.stringify(warnings.mock.calls.map((call) => call.arguments)), /PRIVATE/); +}); + test('startup prewarms only the configured provider and shutdown closes the service', async () => { await startHook(); assert.equal(getToken.mock.callCount(), 1); diff --git a/python/README.md b/python/README.md index fded75b..b5e6365 100644 --- a/python/README.md +++ b/python/README.md @@ -107,7 +107,11 @@ For deployed diagnostics, use [Application Insights](../docs/APPLICATION-INSIGHT Python emits fixed messages with `event_name` and other logging extras, not a universal JSON request summary. Verify which properties the host/export pipeline preserves. -Worker initialization selects `ApiKeyCache` or `AccessTokenCache` from the provider's credential specification. +`EPP_KEY_VAULT_CACHE_ENABLED` controls API-key caching; `EPP_ACCESS_TOKEN_CACHE_ENABLED` controls +OAuth caching. Both default to `true`. Setting the selected switch to `false` acquires credentials +per live request, with no startup preparation, polling, or cross-request reuse. + +With caching enabled, worker initialization selects `ApiKeyCache` or `AccessTokenCache` from the provider's credential specification. Only the selected cache starts: API keys use Key Vault and `cachetools.TTLCache`; access tokens use the MI/Entra SDKs without Key Vault. One daemon loop polls every 30 seconds. Configuration changes require restart. Callers can stop waiting without abandoning shared reads; synchronous SDK I/O uses connect/read diff --git a/python/function_app.py b/python/function_app.py index adb4d1a..dc5ebdc 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -10,7 +10,7 @@ from src import otp_log from src.config import read_config -from src.credentials import CredentialTokenService, report_refresh_failure +from src.credentials import CredentialTokenService, report_refresh_failure, is_cache_enabled from src.jwe import JweDecryptor from src.models import EntraSendOtpPayload, OtpDelivery from src.provider import ( @@ -248,6 +248,8 @@ def _warm_selected_credentials(): report_refresh_failure("configuration") return try: + if not is_cache_enabled(provider.credential_spec, config): + return _credentials.get_credentials(provider, config) except Exception: report_refresh_failure("initialization") diff --git a/python/src/config.py b/python/src/config.py index 3627961..fea615f 100644 --- a/python/src/config.py +++ b/python/src/config.py @@ -19,6 +19,8 @@ class AppConfig: outbound_managed_identity_client_id: str provider_timeout_ms: str | None env: Mapping[str, str] + key_vault_cache_enabled: str | None = None + access_token_cache_enabled: str | None = None def read_config(env: Mapping[str, str] | None = None) -> AppConfig: @@ -35,5 +37,8 @@ def read_config(env: Mapping[str, str] | None = None) -> AppConfig: outbound_client_id=(env.get("EPP_OUTBOUND_CLIENT_ID") or "").strip(), outbound_managed_identity_client_id=(env.get("EPP_OUTBOUND_MI_CLIENT_ID") or "").strip(), provider_timeout_ms=env.get("EPP_PROVIDER_TIMEOUT_MS"), + # Validate only on credential paths so evaluation stays independent. + key_vault_cache_enabled=env.get("EPP_KEY_VAULT_CACHE_ENABLED"), + access_token_cache_enabled=env.get("EPP_ACCESS_TOKEN_CACHE_ENABLED"), env=env, # Preserve raw adapter settings and the injected environment. ) \ No newline at end of file diff --git a/python/src/credentials.py b/python/src/credentials.py index b271ffc..344757e 100644 --- a/python/src/credentials.py +++ b/python/src/credentials.py @@ -69,6 +69,19 @@ def report_refresh_failure(kind: str) -> None: otp_log.credential_refresh_failed(logging.getLogger(__name__), kind) +def is_cache_enabled(spec: Mapping[str, str], config: AppConfig) -> bool: + mode = spec.get("mode") + if mode not in (API_KEY_MODE, OAUTH_MODE): + raise ValueError(CREDENTIAL_ERROR) + setting = config.key_vault_cache_enabled if mode == API_KEY_MODE else config.access_token_cache_enabled + if setting is None: + return True + value = setting.strip().lower() + if value not in ("true", "false"): + raise ValueError(CREDENTIAL_ERROR) + return value == "true" + + def _private_acquisition(load: Callable[[], T]) -> T: for logger in (logging.getLogger(), *logging.Logger.manager.loggerDict.copy().values()): if isinstance(logger, logging.Logger): @@ -188,6 +201,13 @@ def stop(self) -> None: self._closed = True self._token = None + def close(self) -> None: + self.stop() + try: + self._credential.close() + finally: + self._identity.close() + class CredentialTokenService: """Caches credentials for the provider selected by this worker.""" @@ -202,34 +222,44 @@ def __init__(self, secrets: SecretReader, *, cache_options: RefreshOptions | Non self._stop = threading.Event() self.cache: ApiKeyCache | AccessTokenCache | None = None self._pending: Future[ApiKeyCredential | OAuthCredential] | None = None + self._requests: dict[Future[ApiKeyCredential | OAuthCredential], ApiKeyCache | AccessTokenCache] = {} self._next_attempt = 0.0 def get_credentials(self, provider, config: AppConfig) -> ApiKeyCredential | OAuthCredential: return self.resolve(provider.credential_spec, config) + def _create_cache(self, spec: Mapping[str, str], config: AppConfig) -> ApiKeyCache | AccessTokenCache: + if spec.get("mode") == API_KEY_MODE: + return ApiKeyCache(self._secrets, spec, self._clock) + return _private_acquisition(lambda: AccessTokenCache(config, self._clock)) + def resolve(self, spec: Mapping[str, str], config: AppConfig) -> ApiKeyCredential | OAuthCredential: with self._lock: if self._stop.is_set(): raise ValueError(CREDENTIAL_ERROR) - if self.cache is None: - try: - if spec.get("mode") == API_KEY_MODE: - self.cache = ApiKeyCache(self._secrets, spec, self._clock) - elif spec.get("mode") == OAUTH_MODE: - self.cache = _private_acquisition(lambda: AccessTokenCache(config, self._clock)) - else: - raise ValueError(CREDENTIAL_ERROR) - except Exception: - self._report_failure("configuration") - raise ValueError(CREDENTIAL_ERROR) from None - threading.Thread(target=self._loop, daemon=True).start() - value = self.cache.get() - if value is not None: - return value - pending = self.refresh() + try: + enabled = is_cache_enabled(spec, config) + cache = self.cache if enabled and self.cache is not None else self._create_cache(spec, config) + except Exception: + self._report_failure("configuration") + raise ValueError(CREDENTIAL_ERROR) from None + if not enabled: + pending: Future[ApiKeyCredential | OAuthCredential] = Future() + self._requests[pending] = cache + threading.Thread(target=self._run, args=(cache, pending, False), daemon=True).start() + else: + if self.cache is None: + self.cache = cache + threading.Thread(target=self._loop, daemon=True).start() + value = self.cache.get() + if value is not None: + return value + pending = self.refresh() try: return pending.result(timeout=self._wait_timeout) except Exception: + if not enabled: + cache.stop() raise ValueError(CREDENTIAL_ERROR) from None def refresh(self) -> Future[ApiKeyCredential | OAuthCredential]: @@ -246,16 +276,27 @@ def refresh(self) -> Future[ApiKeyCredential | OAuthCredential]: threading.Thread(target=self._run, args=(self.cache, future), daemon=True).start() return future - def _run(self, cache: ApiKeyCache | AccessTokenCache, future: Future[ApiKeyCredential | OAuthCredential]) -> None: + def _run(self, cache: ApiKeyCache | AccessTokenCache, future: Future[ApiKeyCredential | OAuthCredential], + shared: bool = True) -> None: value = None try: - _private_acquisition(cache.refresh) - value = cache.get() + try: + _private_acquisition(cache.refresh) + value = cache.get() + finally: + if not shared: + if isinstance(cache, AccessTokenCache): + cache.close() + else: + cache.stop() except Exception: - pass # Report only the sanitized failure below. + value = None # Report only the sanitized failure below. with self._lock: - self._pending = None - if not self._stop.is_set(): + if shared: + self._pending = None + else: + self._requests.pop(future, None) + if not self._stop.is_set() and not future.done(): if value is None: self._report_failure(cache.stage) future.set_exception(ValueError(CREDENTIAL_ERROR)) @@ -276,3 +317,7 @@ def close(self) -> None: self.cache.stop() if self._pending is not None and not self._pending.done(): self._pending.set_exception(ValueError(CREDENTIAL_ERROR)) + for future, cache in self._requests.items(): + cache.stop() + if not future.done(): + future.set_exception(ValueError(CREDENTIAL_ERROR)) diff --git a/python/tests/test_credential_cache.py b/python/tests/test_credential_cache.py index 467852b..702b2f5 100644 --- a/python/tests/test_credential_cache.py +++ b/python/tests/test_credential_cache.py @@ -12,7 +12,7 @@ import src.credentials as credentials_module from src.config import read_config -from src.credentials import ApiKeyCache, AccessTokenCache, CredentialTokenService +from src.credentials import ApiKeyCache, AccessTokenCache, CredentialTokenService, is_cache_enabled from src.providers.telesign import TelesignProvider AUTH = {"mode": "apiKey", "key_vault_secret_name": "key", "identity_key_vault_secret_name": "id"} @@ -38,6 +38,113 @@ def advance(self, seconds): self.now += seconds +@pytest.mark.parametrize(("value", "expected"), [(None, True), ("true", True), (" TRUE ", True), + ("false", False), (" FaLsE ", False)]) +def test_cache_switches_parse_independently(value, expected): + env = {} if value is None else {"EPP_KEY_VAULT_CACHE_ENABLED": value, "EPP_ACCESS_TOKEN_CACHE_ENABLED": value} + assert is_cache_enabled(AUTH, read_config(env)) is expected + assert is_cache_enabled({"mode": "oauth"}, read_config(env)) is expected + mixed = read_config({"EPP_KEY_VAULT_CACHE_ENABLED": "false", "EPP_ACCESS_TOKEN_CACHE_ENABLED": "true"}) + assert not is_cache_enabled(AUTH, mixed) + assert is_cache_enabled({"mode": "oauth"}, mixed) + + +@pytest.mark.parametrize("value", ["", "1", "0", "yes", "PRIVATE-INVALID"]) +@pytest.mark.parametrize("mode", ["apiKey", "oauth"]) +def test_invalid_selected_switch_fails_before_acquisition(value, mode): + secrets, failures = Mock(), [] + manager = CredentialTokenService(secrets, report_failure=failures.append) + config = read_config({"EPP_KEY_VAULT_CACHE_ENABLED": value, "EPP_ACCESS_TOKEN_CACHE_ENABLED": value}) + try: + with pytest.raises(ValueError, match="^provider credential unavailable$"): + manager.resolve({**AUTH, "mode": mode}, config) + assert failures == ["configuration"] + assert manager.cache is None and manager._pending is None and not manager._requests + secrets.resolve.assert_not_called() + finally: + manager.close() + + +def test_disabled_key_vault_cache_reads_each_bundle_without_polling_stale_fallback_or_cooldown(monkeypatch): + clock = Clock() + version, fail = 1, False + + def read(name): + if fail and name == "id": + raise ValueError("PRIVATE-FAILURE") + return f"{name}-{version}" + + secrets = Mock(resolve=Mock(side_effect=read)) + manager = CredentialTokenService(secrets, cache_options=clock.options, report_failure=lambda _: None) + loop = Mock(side_effect=AssertionError("Disabled cache must not poll")) + monkeypatch.setattr(manager, "_loop", loop) + config = read_config({"EPP_KEY_VAULT_CACHE_ENABLED": "false", "EPP_ACCESS_TOKEN_CACHE_ENABLED": "PRIVATE-UNUSED"}) + try: + assert manager.resolve(AUTH, config)["secret"] == "key-1" + version = 2 + assert manager.resolve(AUTH, config) == {"mode": "apiKey", "secret": "key-2", "identity": "id-2"} + fail = True + with pytest.raises(ValueError, match="unavailable"): + manager.resolve(AUTH, config) + fail, version = False, 3 + assert manager.resolve(AUTH, config)["identity"] == "id-3" + assert secrets.resolve.call_count == 8 + assert manager.cache is None and manager._pending is None and not manager._requests + loop.assert_not_called() + clock.advance(300) + with pytest.raises(ValueError, match="unavailable"): + manager.refresh() + assert secrets.resolve.call_count == 8 + finally: + manager.close() + + +def test_disabled_concurrent_requests_stop_at_shutdown_without_publishing_late_values(): + release, calls = Event(), [] + + def read(name): + calls.append(name) + assert release.wait(3) + return "PRIVATE-LATE-KEY" + + manager = CredentialTokenService(Mock(resolve=read)) + config = read_config({"EPP_KEY_VAULT_CACHE_ENABLED": "false"}) + try: + with ThreadPoolExecutor(max_workers=3) as pool: + requests = [pool.submit(manager.resolve, AUTH, config) for _ in range(3)] + wait_until(lambda: len(calls) == 6) + manager.close() + for request in requests: + with pytest.raises(ValueError, match="unavailable"): + request.result(timeout=1) + release.set() + wait_until(lambda: not manager._requests) + assert manager.cache is None + with pytest.raises(ValueError, match="unavailable"): + manager.resolve(AUTH, config) + finally: + release.set() + manager.close() + + +def test_disabled_acquisition_timeout_discards_late_values_without_retry_cooldown(): + release = Event() + secrets = Mock(resolve=Mock(side_effect=lambda _: release.wait(3) and "value")) + manager = CredentialTokenService(secrets, cache_options={"wait_timeout": 0.1}, report_failure=lambda _: None) + config = read_config({"EPP_KEY_VAULT_CACHE_ENABLED": "false"}) + try: + with pytest.raises(ValueError, match="unavailable"): + manager.resolve(AUTH, config) + release.set() + wait_until(lambda: not manager._requests) + assert manager.cache is None + assert manager.resolve(AUTH, config)["secret"] == "value" + assert secrets.resolve.call_count == 4 + finally: + release.set() + manager.close() + + def test_library_cache_shares_parallel_reads_and_serves_a_complete_pair_during_refresh(monkeypatch): clock = Clock() key_started, id_started, release = Event(), Event(), Event() diff --git a/python/tests/test_credential_sdk.py b/python/tests/test_credential_sdk.py index 8f15d6c..0db9e05 100644 --- a/python/tests/test_credential_sdk.py +++ b/python/tests/test_credential_sdk.py @@ -12,7 +12,8 @@ @pytest.mark.parametrize("refresh_in", [None, 60]) -def test_real_provider_sdk_reuses_tokens_and_preserves_refresh_metadata(monkeypatch, refresh_in): +@pytest.mark.parametrize("cache_enabled", [None, " TrUe ", "false"]) +def test_real_provider_sdk_honors_cache_setting_and_refresh_metadata(monkeypatch, refresh_in, cache_enabled): token_endpoint_calls = [] mi_calls = [] @@ -46,8 +47,9 @@ def managed(*args, **kwargs): return SimpleNamespace(token="PRIVATE-ASSERTION", expires_on=time.time() + 3600) monkeypatch.setattr(requests.Session, "request", send) - monkeypatch.setattr(credentials_module, "ManagedIdentityCredential", Mock( - return_value=Mock(spec=["get_token"], get_token=Mock(side_effect=managed)))) + identity = Mock(spec=["get_token", "close"], get_token=Mock(side_effect=managed)) + create_identity = Mock(return_value=identity) + monkeypatch.setattr(credentials_module, "ManagedIdentityCredential", create_identity) secrets = Mock() now = time.time() manager = CredentialTokenService(secrets, cache_options={ @@ -58,20 +60,28 @@ def managed(*args, **kwargs): "EPP_OUTBOUND_CLIENT_ID": "22222222-2222-2222-2222-222222222222", "EPP_OUTBOUND_MI_CLIENT_ID": "33333333-3333-3333-3333-333333333333", "EPP_PROVIDER_SCOPE": "api://provider/.default", + **({"EPP_ACCESS_TOKEN_CACHE_ENABLED": cache_enabled} if cache_enabled is not None else {}), + "EPP_KEY_VAULT_CACHE_ENABLED": "PRIVATE-UNUSED", }) try: with ThreadPoolExecutor(max_workers=10) as pool: results = list(pool.map(lambda _: manager.resolve({"mode": "oauth"}, config), range(10))) assert all(value["access_token"] == "PRIVATE-PROVIDER" for value in results) - assert len(token_endpoint_calls) == 1 - assert len(mi_calls) == 2 + enabled = cache_enabled != "false" + assert len(token_endpoint_calls) == (1 if enabled else 10) + assert len(mi_calls) == (2 if enabled else 20) assert manager.resolve({"mode": "oauth"}, config)["access_token"] == "PRIVATE-PROVIDER" - assert len(token_endpoint_calls) == 1 and len(mi_calls) == 2 - assert len(token_endpoint_calls) == 1 and len(mi_calls) == 2 now += 60 assert manager.resolve({"mode": "oauth"}, config)["access_token"] == "PRIVATE-PROVIDER" - manager.refresh().result(timeout=3) - assert len(token_endpoint_calls) == 1 + if enabled: + manager.refresh().result(timeout=3) + assert len(token_endpoint_calls) == 1 + assert create_identity.call_count == 1 + identity.close.assert_not_called() + else: + assert len(token_endpoint_calls) == 12 + assert create_identity.call_count == identity.close.call_count == 12 + assert manager.cache is None and not manager._requests secrets.resolve.assert_not_called() finally: manager.close() diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index c00ae6b..177f4c1 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -12,6 +12,7 @@ import function_app import src.provider as provider_module from src.jwe import JweDecryptor +from src.credentials import CredentialTokenService _KEY = jwk.JWK.generate(kty="RSA", size=2048) _PRIVATE_PEM = _KEY.export_to_pem(True, None).decode() @@ -36,6 +37,8 @@ def _isolate(monkeypatch): monkeypatch.setenv("EPP_PROVIDER_ENDPOINT", "https://qa4.example/oauth/messages") monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", "oauth") monkeypatch.delenv("EPP_PROVIDER_CHANNEL", raising=False) + monkeypatch.delenv("EPP_KEY_VAULT_CACHE_ENABLED", raising=False) + monkeypatch.delenv("EPP_ACCESS_TOKEN_CACHE_ENABLED", raising=False) monkeypatch.setattr(function_app, "_decryptor", JweDecryptor(function_app.os.environ)) monkeypatch.setattr(function_app, "_credentials", Mock( get_credentials=Mock(return_value={"mode": "oauth", "access_token": "provider-token"}))) @@ -74,6 +77,52 @@ def _events(caplog): return [record for record in caplog.records if hasattr(record, "event_name")] +@pytest.mark.parametrize(("provider", "mode", "switch"), [ + ("telesign", "apiKey", "EPP_KEY_VAULT_CACHE_ENABLED"), + ("soprano", "oauth", "EPP_ACCESS_TOKEN_CACHE_ENABLED"), +]) +def test_disabled_cache_skips_startup_and_evaluation_but_not_live_credentials(monkeypatch, provider, mode, switch): + monkeypatch.setenv("EPP_PROVIDER_NAME", provider) + monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", mode) + for name in ("EPP_KEY_VAULT_CACHE_ENABLED", "EPP_ACCESS_TOKEN_CACHE_ENABLED"): + monkeypatch.setenv(name, "false" if name == switch else "PRIVATE-UNUSED") + function_app._credentials.get_credentials.return_value = { + "mode": mode, "secret": "key", "identity": "customer", "access_token": "token", + } + provider_module.requests.request.return_value = Mock( + status_code=200, json=Mock(return_value={"status": {"code": 3001} if mode == "apiKey" else "ENROUTE"})) + function_app._warm_selected_credentials() + response = _HANDLER(_request(_envelope(mode=2))) + assert response.status_code == 200 and json.loads(response.get_body())["nonce"] == _NONCE + function_app._credentials.get_credentials.assert_not_called() + provider_module.requests.request.assert_not_called() + for _ in range(2): + assert _HANDLER(_request(_envelope())).status_code == 200 + assert function_app._credentials.get_credentials.call_count == 2 + + +@pytest.mark.parametrize(("provider", "mode"), [("telesign", "apiKey"), ("soprano", "oauth")]) +def test_invalid_cache_setting_fails_live_but_not_evaluation(monkeypatch, caplog, provider, mode): + monkeypatch.setenv("EPP_PROVIDER_NAME", provider) + monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", mode) + monkeypatch.setenv("EPP_KEY_VAULT_CACHE_ENABLED", "PRIVATE-INVALID") + monkeypatch.setenv("EPP_ACCESS_TOKEN_CACHE_ENABLED", "PRIVATE-INVALID") + secrets = Mock() + service = CredentialTokenService(secrets) + monkeypatch.setattr(function_app, "_credentials", service) + try: + function_app._warm_selected_credentials() + assert _HANDLER(_request(_envelope(mode=2))).status_code == 200 + response = _HANDLER(_request(_envelope())) + assert response.status_code == 502 and "nonce" not in json.loads(response.get_body()) + secrets.resolve.assert_not_called() + provider_module.requests.request.assert_not_called() + assert any(record.event_name == "credential_refresh_failed" for record in _events(caplog)) + assert "PRIVATE" not in caplog.text + finally: + service.close() + + def test_invalid_requests_return_contract_reasons_before_provider_work(caplog): caplog.set_level(logging.INFO) valid = _envelope(encryptedDeliveryContext="unused") diff --git a/setup/docs/README.md b/setup/docs/README.md index 56003bb..4179678 100644 --- a/setup/docs/README.md +++ b/setup/docs/README.md @@ -62,10 +62,12 @@ and notify the resource owner; a budget notification does not stop spending. Inc Key Vault, telemetry ingestion/retention, and the provider's charges, not just Function executions. Stopping a Function does not necessarily stop its plan or supporting-service charges. -**Cache settings require runtime support.** Setup only writes the two app settings above; they do -not change caching in a Function package that does not read them. Runtime cache-reader changes are -separate and must be released and deployed before these settings take effect. Rerunning setup -restores the selected plan's values. The decryption-key Key Vault reference remains platform-managed. +**Deploy a Function package that supports these switches.** Older packages ignore them. +Each switch independently controls its provider credential cache; unset defaults to `true`. +With the selected cache disabled, live requests acquire credentials on demand without startup +preparation, polling, or cross-request reuse. Values must be trimmed, case-insensitive `true` or +`false`; restart after edits. Rerunning setup restores the selected plan's values. +The decryption-key Key Vault reference remains platform-managed. Use `-ServicePlan FC1` or `-ServicePlan EP1` to skip the prompt. Unattended setup requires this parameter; it never silently chooses a paid plan. The approval and deployment summary include diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md index 965621e..dd0eaca 100644 --- a/setup/docs/Troubleshooting.md +++ b/setup/docs/Troubleshooting.md @@ -132,8 +132,11 @@ validation does not prove successful publication, runtime startup, Easy Auth enf Check `EPP_KEY_VAULT_CACHE_ENABLED` and `EPP_ACCESS_TOKEN_CACHE_ENABLED` on the serving Function App. Setup writes both as `"false"` for FC1 or `"true"` for EP1; rerunning setup restores those values. These settings only affect a deployed Function package that implements the cache readers. -This setup change does not add runtime cache control, so `"false"` alone does not disable caching -in an unsupported package. Deploy a supporting runtime release before relying on these settings. +Deploy a supporting runtime release and restart after edits; older packages ignore the switches. +Values must be `true` or `false` (case-insensitive, trimmed); unset defaults to enabled. +Invalid selected values fail live credential acquisition closed without blocking evaluation. +Only the selected provider's authentication mode chooses the switch; disabling it skips startup +preparation and cross-request reuse, not live credential acquisition or the platform's decryption-key reference. ## appservice list-locations rejects EP1