Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 11 additions & 4 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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>.vault.azure.net/secrets/<private-key-secret>/)`
Expand Down
36 changes: 26 additions & 10 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions dotnet/Src/AppConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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"),
};
}
38 changes: 32 additions & 6 deletions dotnet/Src/CredentialTokenService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,12 @@ public async Task<ProviderCredentials> 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);
Expand All @@ -65,6 +63,33 @@ public async Task<ProviderCredentials> GetCredentialsAsync(
}
}

private static async Task<ProviderCredentials> 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;
Expand All @@ -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
Expand Down
Loading
Loading