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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ 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 automatically per worker](docs/CONTRACT.md#credential-caching-and-refresh):
Telesign's Key Vault credentials, Soprano's managed-identity assertion, and its final Entra access
token are cached and refreshed before expiry. Refresh never sends an OTP. Evaluation handling still
skips provider work, but a worker with a configured provider can independently acquire credentials
at startup or during background refresh. Leave `EPP_PROVIDER_NAME` unset for local evaluation-only
work without credential acquisition. No extra refresh app settings are required.

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>/)`
for `EPP_DECRYPTION_KEY_PEM` in Azure app settings, where the platform resolves it.
Expand Down
78 changes: 69 additions & 9 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,20 @@ 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 are reused for the configured tenant/application/identity; each acquisition
uses the selected scope. JavaScript and .NET pass one 2.5-second cancellation signal/token through
both exchange stages. Python uses 2.5-second connect/read inactivity timeouts, not a total deadline.
Configured SDK transport retries are disabled. Managed-identity discovery may involve additional
SDK operations; this is not an end-to-end delivery deadline. JavaScript suppresses SDK logs only
in the acquisition's asynchronous context. Python filters Azure Identity/Core/MSAL records on
configured handlers in that context; configure logging sinks before handling requests. .NET disables
credential diagnostics. Keep platform body tracing off and never log credential objects or tokens.
Credential instances and their SDK caches are reused for the configured tenant/application/identity.
One [worker-local refresh loop](#credential-caching-and-refresh) warms both exchange stages; a
snapshot of the latest provider token keeps refresh off the delivery path. JavaScript and .NET
bound the shared acquisition to 2.5 seconds, independent of individual waiters. JavaScript links
cancellation through an SDK HTTP-client wrapper because `getToken` options alone are insufficient
in the installed SDK. Python bounds caller waits and SDK connect/read inactivity to 2.5 seconds;
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; failed refreshes use the fixed polling cadence described
below. 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
handlers in that context; configure logging sinks before handling requests. .NET disables credential
diagnostics. Keep platform body tracing off and never log credential objects or tokens.

When migrating from the earlier optional-JWT branch, replace `EPP_PROVIDER_APPLICATION_ID` with
`EPP_OUTBOUND_CLIENT_ID` and `EPP_PROVIDER_MI_CLIENT_ID` with `EPP_OUTBOUND_MI_CLIENT_ID`.
Expand Down Expand Up @@ -179,6 +185,11 @@ Key Vault reads and outbound provider HTTP are skipped. No provider name, endpoi
are needed. Platform authentication and resolution of the decryption-key reference may still require
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.
No background task dispatches an OTP. A worker without `EPP_PROVIDER_NAME` performs no credential
prewarming, and evaluation does not require that prewarming 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 @@ -321,6 +332,49 @@ the configured adapter, which builds the provider's SMS or voice API call. Purch
provider does not install an adapter: add and register that provider's adapter first. Purchase,
subscription activation and changing tenant policy belong to provisioning, not this Function.

### Credential caching and refresh

Provider credential management is automatic for configured providers in every runtime. It changes
when credentials are fetched, not the HTTP/nonce contract, caller authentication, FIC, provider
selection or provider request format. The decryption-key Key Vault reference remains separate and
is still resolved by the platform; this cache does not rotate or replace JWE keys.

The selected provider's manifest determines which of two concrete cache classes is created:

| Authentication mode | Cache | Acquisition |
|---|---|---|
| `apiKey` | `ApiKeyCache` | Fetch the manifest's Key Vault secrets using managed identity. Cache the complete key/customer-ID bundle in .NET `MemoryCache`, JavaScript `lru-cache`, or Python `cachetools.TTLCache`. |
| `oauth` | `AccessTokenCache` | Reuse Azure Identity's managed-identity and client-assertion credentials and their SDK caches. Retain only the latest usable provider token. No Key Vault access. |

Only the selected cache starts. Its credential configuration is bound on first use; app-setting
changes require a worker restart, not live cache switching. API-key bundles are published only
after all required reads succeed. Refresh targets four minutes after retrieval and hard expiry
is five minutes; reads or failed refreshes never extend the lifetime. Provider tokens retain their
original SDK expiry and are unusable with 30 seconds or less remaining. Tokens are never persisted.

The shared coordinator prepares credentials at startup and polls the selected cache every 30 seconds.
`ApiKeyCache` skips retrieval until its refresh target is due; `AccessTokenCache` consults both SDK
credentials and lets the SDK decide whether network acquisition is needed. There is no separate MI
cache, adaptive expiry timer, or exponential retry policy. Failures retry on a later poll; requests
cannot start another acquisition within the same 30-second window. This is best-effort scheduling,
not an exact refresh deadline. Timing policy uses named constants, not extra app settings.

Concurrent cold requests share one acquisition. Requests with usable cached credentials do not
wait for background refresh. After hard expiry, they join the shared acquisition or fail closed.
JavaScript's app-start hook, Python's initialization thread and .NET's hosted service start only
credential preparation, never provider delivery. Shutdown stops polling and prevents late
publication; `close()`/`Dispose()` is terminal. JavaScript and .NET propagate the 2.5-second
acquisition deadline to SDK HTTP. Python bounds caller waits and SDK connect/read inactivity to
2.5 seconds but cannot forcibly cancel synchronous I/O; daemon secret reads stay shared until
both finish and cannot block process exit. Missing/broken provider configuration does not prevent
evaluation.

**Prewarming does not guarantee the first request meets the caller's timeout.** Worker readiness,
scale-out and ingress overhead still matter. Refresh does not retry or deduplicate provider sends.
Background failures log only `credential_refresh_failed`, `cacheKind` and the fixed
`credential_unavailable` reason, without request IDs, credential values or SDK exception details.
Per-request `providerCredentialElapsedMs` continues to measure the caller's resolution time.

---

## 5. Required behaviors
Expand Down Expand Up @@ -477,10 +531,13 @@ Each language keeps lightweight offline tests covering representative applicatio
- Bundled adapter request formats and static provider credentials.
- Fail-closed outcomes, missing credentials, HTTPS guards and timeouts.
- Envelope validation and real JWE decryption/tamper rejection.
- Evaluation without provider I/O.
- Evaluation handling without provider I/O; configured-provider startup refresh is tested separately.
- Awaited delivery, nonce acknowledgement and privacy-safe logging, including the shared
service-event order and summary field set in [contract.json](../tests/fixtures/contract.json),
identifier provenance, error paths, provider-body timeouts and concurrent request isolation.
- Selected-cache-only startup, shared credential retrieval, fixed refresh/retry cadence, hard expiry,
token lifetime preservation and shutdown using controlled clocks and
fake dependencies. JavaScript tests also exercise cancellation through the actual SDK pipeline.

The sample deliberately omits exhaustive input permutations and SDK internals. These tests use
local keys and mocked external services; they do not send SMS and **do not test Easy Auth or platform
Expand All @@ -501,6 +558,9 @@ not prove handset delivery or support for every provider feature.
timing architecture merely because the setup script deploys it.
- The outbound timeout is not an end-to-end deadline. Cold starts, platform authentication and Key
Vault access can exceed the caller's budget; Python uses connect/read inactivity timeouts.
- Credential caches are process-local and proactive preparation is best-effort, not an Azure
traffic-readiness guarantee. Provider delivery still waits for acceptance; background refresh
is not background OTP delivery, a queue, or protection against duplicate sends.
- Voice text is forwarded unchanged. Digit-by-digit rendering required by the setup guide must be
verified for the chosen voice integration; unspaced numeric text is not guaranteed to be spoken correctly.
- Full body-size/content-type and E.164 validation, subscription provisioning, certification,
Expand Down
11 changes: 11 additions & 0 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,14 @@ Use [CONTRACT.md](CONTRACT.md) for the full request contract and production limi
caller received the response. Credential resolution can use caches. `providerEndpoint` contains the
base URL and API path only, without query strings, userinfo or fragments; support IDs remain raw
so customers can share the exact reference with Microsoft/provider support.

Provider credentials are [prewarmed and refreshed per worker](CONTRACT.md#credential-caching-and-refresh)
automatically when a provider is configured. This contacts Key Vault or Entra without sending an OTP.
Evaluation requests still skip those dependencies, but independent background preparation may run
alongside them. For local evaluation-only use without managed identity, leave `EPP_PROVIDER_NAME`
unset. Check for `credential_refresh_failed` warnings before live testing.

Compare fresh-worker, warm, expiry/rotation and concurrent-request behavior. A warmup or passing
offline test does not prove the first live request fits the caller's timeout. Background refresh
does not retry or deduplicate a provider send. Do not rerun provisioning or change FIC, app
registration, provider settings or decryption keys to deploy this code-only improvement.
1 change: 1 addition & 0 deletions dotnet/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@

builder.Services.AddSingleton<ProviderRegistry>();
builder.Services.AddSingleton<DispatchEngine>();
builder.Services.AddHostedService<CredentialRefreshService>();

builder.Build().Run();
12 changes: 11 additions & 1 deletion dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,26 @@ six-digit numeric run that is not part of a longer number and repeats the comple

## Source

The hosted service selects `ApiKeyCache` or `AccessTokenCache` from the provider manifest's auth mode.
Only the selected cache starts: API keys use Key Vault and framework `MemoryCache`; access tokens
use the MI/Entra SDKs without Key Vault. One periodic timer polls every 30 seconds. Configuration
changes require restart. Each shared acquisition owns
its cancellation budget; a waiter cannot cancel another request's retrieval. Disposal stops refresh
and prevents late publication. See the [refresh contract](../docs/CONTRACT.md#credential-caching-and-refresh)
for expiry, sanitized failure logs and cold-start limits. Evaluation remains independent.

| Source | Purpose |
|---|---|
| [Program.cs](Program.cs) | Host and adapter registration |
| [Functions/SendOtp.cs](Functions/SendOtp.cs) | HTTP handler |
| [Src/AppConfig.cs](Src/AppConfig.cs) | Shared deployment settings |
| [Src/DispatchEngine.cs](Src/DispatchEngine.cs) | Envelope/JWE handling and dispatch |
| [Src/ProviderCredentials.cs](Src/ProviderCredentials.cs) | `ApiKeyCache`, `AccessTokenCache` and their shared refresh coordinator |
| [Src/CredentialRefreshService.cs](Src/CredentialRefreshService.cs) | Per-worker startup and shutdown integration |
| [Src/RequestLog.cs](Src/RequestLog.cs) | Request-scoped [service events and summaries](../docs/CONTRACT.md#application-logs) with explicit ID sources |
| [Src/ProviderRegistry.cs](Src/ProviderRegistry.cs), [Src/IProviderAdapter.cs](Src/IProviderAdapter.cs) | Adapter lookup and contract |
| [Src/Providers/](Src/Providers/) | Adapter manifests and API-specific implementations |
| [Src/SecretResolver.cs](Src/SecretResolver.cs) | Cached Key Vault access via managed identity |
| [Src/SecretResolver.cs](Src/SecretResolver.cs) | Key Vault transport; `ISecretResolver.ResolveAsync` accepts cancellation and `ApiKeyCache` owns the bundle |
| [Src/OutcomeMapper.cs](Src/OutcomeMapper.cs), [Src/Models.cs](Src/Models.cs) | Outcomes and shared records |

Implement `IProviderAdapter` and register it in [Program.cs](Program.cs) without adding provider-specific
Expand Down
14 changes: 14 additions & 0 deletions dotnet/Src/CredentialRefreshService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.Extensions.Hosting;

namespace Epp.Otp;

internal sealed class CredentialRefreshService(DispatchEngine engine) : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken) => engine.StartCredentialRefreshAsync(cancellationToken);

public Task StopAsync(CancellationToken cancellationToken)
{
engine.Dispose();
return Task.CompletedTask;
}
}
Loading
Loading