From f600e1ec7a63fa0aab0d7c0a59928fb63fb19d66 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Tue, 15 Sep 2026 15:16:27 -0700 Subject: [PATCH] Add optional federated JWT authentication for Soprano --- README.md | 106 +++++++++- docs/CONTRACT.md | 108 +++++++++- docs/ONBOARDING.md | 27 +++ docs/local.settings.sample.json | 10 +- dotnet/README.md | 7 + dotnet/Src/DispatchEngine.cs | 16 +- dotnet/Src/IProviderAdapter.cs | 2 + dotnet/Src/Models.cs | 6 +- dotnet/Src/Providers/SopranoProvider.cs | 72 +++++++ dotnet/tests/EngineTests.cs | 197 +++++++++++++++++- javascript/README.md | 7 + javascript/package-lock.json | 1 + javascript/package.json | 1 + javascript/src/functions/SendOtp.js | 2 +- javascript/src/functions/dispatch.js | 33 ++- javascript/src/functions/providers/soprano.js | 57 ++++- javascript/test/dispatch.test.js | 84 ++++++++ javascript/test/sendotp.test.js | 96 ++++++++- python/README.md | 7 + python/src/dispatch.py | 11 + python/src/providers/soprano.py | 67 ++++++ python/tests/test_engine.py | 87 ++++++++ python/tests/test_function_app.py | 83 +++++++- 23 files changed, 1055 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9b04ac9..28b4dc3 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,10 @@ extend it deliberately if you add runtime assets, and never put secrets in appli SAS → Easy Auth → anonymous HTTP handler (`POST /api/SendOtp`, validate envelope + decrypt JWE) → configured provider (API key) → HTTP result with nonce on success. +For Soprano with the JWT flag enabled, the Function exchanges a managed-identity assertion for an +application token in the provider tenant, without an application secret, and adds it to the +API-ID/key-authenticated send. SAS supplies the encrypted delivery +payload, not that provider JWT. See [Soprano JWT setup](#soprano-jwt-setup). Only provider acceptance returns the nonce for live requests. Incoming `mode: 2` (evaluation) is the generic shutter: after platform authentication, validate and decrypt, then echo the nonce without calling a provider. @@ -125,8 +129,9 @@ variables; Azure Functions Core Tools loads that `Values` object for local runs. The sample uses `node`; change it to `python` or `dotnet-isolated` for those runtimes. Replace the provider, endpoint, vault and test-key placeholders before use. Its storage value assumes **Azurite -is running**; do not copy `UseDevelopmentStorage=true` into Azure. Optional settings stay in the table -below rather than appearing as required placeholders in the sample. Keep explanatory comments outside +is running**; do not copy `UseDevelopmentStorage=true` into Azure. The optional Soprano JWT settings +are included with the feature disabled; leave them unused for other providers. Other optional settings +are listed below. Keep explanatory comments outside `Values`, otherwise the host loads them as environment variables too. The local settings file is an environment-variable input for the Functions host, **not a serialized @@ -143,9 +148,14 @@ how code accesses configuration, not the environment-variable names. | `EPP_PROVIDER_NAME` | Live delivery | Selected adapter's manifest ID. No default provider. | | `EPP_PROVIDER_ENDPOINT` | Live delivery | HTTPS **base URL**, in the same environment as the provider credentials; the adapter adds its route. | | `EPP_PROVIDER_TIMEOUT_MS` | Optional | Decimal milliseconds. Defaults to `1500`, capped at `2500`; not an end-to-end deadline. | +| `EPP_PROVIDER_JWT_ENABLED` | Optional, Soprano only | Default off. `true` exchanges a managed-identity assertion for an application token in the provider tenant. API-ID/key headers remain mandatory; unavailable token means API-key-only. | +| `EPP_PROVIDER_TENANT_ID` | Soprano JWT acquisition | Provider/resource tenant where the final application token is requested. | +| `EPP_PROVIDER_APPLICATION_ID` | Soprano JWT acquisition | Application (client) ID of the calling app registration trusted by Soprano, not the provider API's Application ID. | +| `EPP_PROVIDER_MI_CLIENT_ID` | Soprano JWT acquisition | Client ID of an attached user-assigned managed identity trusted by that app registration's federated credential. Not its Object (principal) ID. | +| `EPP_PROVIDER_SCOPE` | Soprano JWT acquisition | Provider API Application ID or Application ID URI plus `/.default`, exactly as agreed with the provider. Required when enabling JWT; no default. | | `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. | +| `AZURE_CLIENT_ID` | Optional, Key Vault only | Client ID of a user-assigned managed identity for Key Vault access. Leave empty/unset for the system-assigned identity. Independent of the provider federation identity. | 1. **Locally:** create private local settings beside the chosen runtime's host file, following its [JavaScript](javascript/README.md#environment-configuration), [Python](python/README.md#environment-configuration) @@ -168,8 +178,94 @@ or base64 PEM directly; use a reference such as `@Microsoft.KeyVault(SecretUri=h for `EPP_DECRYPTION_KEY_PEM` in Azure app settings, where the platform resolves it. Configure inbound issuer/audience/caller trust in **Easy Auth**, not these application variables. -Incoming `tenantId`, `channel`, `mode` and `ttlSeconds` are request data. No outbound OAuth settings -are supported by this main-based implementation. +Incoming `tenantId`, `channel`, `mode` and `ttlSeconds` are request data and cannot select the +managed identity or provider scope. The Function ignores any incoming `providerJwt` or provider-token header. + +## Soprano JWT Setup + +**No application client secret is needed.** Azure manages the Function's identity credentials; +Entra issues and signs the token. Keep the existing `soprano-api-id` and `soprano-api-key` secrets +in Key Vault. The JWE decryption key is also unchanged. + +1. Attach an existing **user-assigned managed identity** to the Function. Set `EPP_PROVIDER_MI_CLIENT_ID` + to its Client ID. Keep the existing system-assigned identity or `AZURE_CLIENT_ID` for Key Vault. +2. Configure or reuse a federated identity credential on the **calling app registration**, which must + share the managed identity's home tenant. Its issuer is `https://login.microsoftonline.com//v2.0`, + subject is the identity's **Object (principal) ID**, and audience is `api://AzureADTokenExchange` + (without `/.default`). For another provider tenant, the calling app must be multitenant, provisioned + there, and authorized for the provider API. This is not a credential on the provider API registration. +3. Configure the exchange and enable JWT after confirming Soprano accepts the calling application: + + ```json + { + "EPP_PROVIDER_JWT_ENABLED": "true", + "EPP_PROVIDER_TENANT_ID": "", + "EPP_PROVIDER_APPLICATION_ID": "", + "EPP_PROVIDER_MI_CLIENT_ID": "", + "EPP_PROVIDER_SCOPE": "/.default" + } + ``` + + Use the exact resource identifier agreed with the provider, which may instead be + `api:///.default`. The QA4 example is + `32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default`; it is not a built-in default. + +4. Verify SMS and Voice with the flag off and on. Off means no token request. Missing settings, + unavailable managed identity, or exchange errors use API keys alone. Check the `SopranoAuth=api-key+jwt` log + for the request to prove a token was actually attached. A provider rejection never triggers a resend. + +The flow is **SAS JWE -> Function decrypts -> user-assigned identity gets an exchange assertion -> +ClientAssertionCredential requests an application token from the provider tenant -> Soprano receives +API-ID/key plus the final Bearer JWT**. The first token is for `api://AzureADTokenExchange/.default`; +it is never sent to Soprano. SAS's HTTP Authorization is validated separately by Easy Auth and is +never forwarded. Neither the OTP nor the JWE is included in either token request. + +All three implementations reuse Azure Identity `ManagedIdentityCredential` and `ClientAssertionCredential` +so the SDKs handle assertion/application-token caching and refresh: +[JavaScript `acquireToken`](javascript/src/functions/providers/soprano.js), +[Python `acquire_token`](python/src/providers/soprano.py), or +[.NET `AcquireTokenAsync`](dotnet/Src/Providers/SopranoProvider.cs). +The Function checks token presence and expiry metadata, not JWT structure or signatures. +**Soprano validates the provider JWT; Easy Auth validates the inbound caller JWT; the Function +validates and decrypts the JWE.** See the [contract](docs/CONTRACT.md#optional-soprano-provider-jwt) +for timeouts and fallback implications. + +For local tests, mock the managed-identity credential as the test suites do. Ordinary development +machines do not have the Azure managed-identity endpoint; CLI login is not a fallback. A local +API-key send with an injected secret resolver does not verify managed-identity JWT acquisition. + +When migrating from the earlier secret-based implementation, replace `EPP_PROVIDER_CLIENT_ID` with +`EPP_PROVIDER_APPLICATION_ID`, keep the intended provider tenant, and remove `EPP_PROVIDER_CLIENT_SECRET_NAME`. +Attach/configure the trusted user-assigned identity instead. Revoke only obsolete credentials dedicated to that flow +after confirming no other workload uses them. Do not delete provider API keys or JWE keys. + +See [Microsoft's managed-identity federation setup](https://learn.microsoft.com/entra/workload-id/workload-identity-federation-config-app-trust-managed-identity) +for the same-tenant trust requirement and multitenant resource access. + +### QA4 Live Verification + +On September 15, 2026, a three-round matrix exercised the **public deployed HTTP endpoint** +for each language, using real JWE payloads, Key Vault reads, and provider HTTP. The authorized test +application obtained its ingress tokens through the existing MSI federation, without creating passwords. + +| Runtime | API-key SMS | API-key Voice | Federated-JWT SMS | Federated-JWT Voice | +|---|---|---|---|---| +| JavaScript | 3/3 accepted | 3/3 accepted | 3/3 accepted | 3/3 accepted | +| Python | 3/3 accepted | 3/3 accepted | 3/3 accepted | 3/3 accepted | +| .NET | 3/3 accepted | 3/3 accepted | 3/3 accepted | 3/3 accepted | + +All 36 requests returned HTTP `200` with matching nonce and correlation ID. Per-request authentication +logs confirmed `api-key` for all 18 API-key cases and `api-key+jwt` for all 18 JWT cases; no JWT pass +was an API-key fallback. No live request was retried. Readiness delays were handled with non-delivery +evaluation requests. Afterward, original settings (federated JWT enabled) and SAS-only caller allowlists +were restored, test caller denial was verified on all three apps, and temporary ingress tokens were +cleared. Existing identities, federated trust, and application passwords were unchanged. This verifies +the deployed application flow with a dedicated test caller, not execution by the actual SAS service +or handset receipt. Voice used `en-US`. It does not establish which credential Soprano prioritizes +when both JWT and API-key headers are present. Earlier secret-based and direct-MSI experiments remain +in historical reports and are not evidence for this federated flow. Subsequent source-review fixes +to SDK log privacy have offline regression coverage, but are not included +in this live result until redeployed and verified. ## Security diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 73326ff..7096be8 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -99,9 +99,9 @@ nonblank strings. Supply the password explicitly to preserve leading zeros; it i from `message`. These values are forwarded unchanged as `voice.text2voice`, without a top-level `text` field. Missing or invalid speech returns `400` before credential lookup or provider HTTP. SMS continues to use `message`, and evaluation continues to skip provider-specific validation and I/O. -Soprano authentication remains API-key-only (`X-MEMS-API-ID` and `X-MEMS-API-Key`, resolved from -`soprano-api-id` and `soprano-api-key` in Key Vault). No provider JWT, OAuth flow, token endpoint, -or bearer-token forwarding is added. Existing platform caller authentication is unchanged. +Soprano always requires `X-MEMS-API-ID` and `X-MEMS-API-Key`, resolved from `soprano-api-id` and +`soprano-api-key` in Key Vault. The optional provider JWT described below supplements these headers; +it never replaces them. Existing platform caller authentication is unchanged. Use a speech language supported by the selected Soprano endpoint and account. On QA4, an API-key voice request using `en` returned HTTP `400` with error code `400101`; the same request structure @@ -115,6 +115,98 @@ Its password fields are `beforePassword`, `passwordText`, and `afterPassword`. T the reference integration's JSON `/messages/omnimsg` contract instead; do not mix the form API's language IDs, field names, or `ApiResponse.StatusCode` response format with this JSON interface. +### Optional Soprano provider JWT + +**The Azure Function obtains the provider JWT from Entra. SAS supplies only the JWE delivery +payload, not a Soprano token.** SAS still authenticates its HTTP call with a Function-audience token +in `Authorization`, validated by Easy Auth. The outbound provider token is separate. A `providerJwt` +field or provider-token header in the incoming request is ignored and never forwarded. + +For live Soprano SMS/Voice with `EPP_PROVIDER_JWT_ENABLED=true`, after checking the API-ID/key and +provider URL, `ManagedIdentityCredential` uses `EPP_PROVIDER_MI_CLIENT_ID` to obtain an assertion for +`api://AzureADTokenExchange/.default`. `ClientAssertionCredential` exchanges it in `EPP_PROVIDER_TENANT_ID` +as `EPP_PROVIDER_APPLICATION_ID` for `EPP_PROVIDER_SCOPE`. Only the final application token is forwarded. +The scope is the provider API's Application ID or Application ID URI plus `/.default`; there is no default. +The provider identity must be an attached user-assigned identity. Key Vault's existing identity selection +(`AZURE_CLIENT_ID`, or system-assigned when unset) is unchanged and independent. +No application secret, additional vault secret, or manually signed JWT is required for token acquisition. +Request `tenantId`, incoming Authorization, developer login, and the decryption key are never used to +select or authenticate this identity. Provider API ID/key secrets remain mandatory. + +| Configuration / acquisition result | Soprano request | +|---|---| +| Flag missing, false, or unrecognized | API-ID/key only; no token acquisition | +| Flag true; federation settings missing, identity unavailable, either token request fails, or result is unusable | API-ID/key only | +| Flag true; usable token obtained from Entra | API-ID/key plus `Authorization: Bearer ` | +| API ID or key missing | Fail before token acquisition or provider HTTP | + +Only the string `true`, case-insensitive with surrounding whitespace ignored, enables +`EPP_PROVIDER_JWT_ENABLED`. It applies only to Soprano SMS and Voice. Evaluation mode still skips +provider lookup, credentials, token acquisition, and provider HTTP. Live payloads keep `shutterMode: false`; +Voice still uses the full `voice.text2voice` object with a provider-supported language such as `en-US`. + +Acquisition runs in [JavaScript `acquireToken`](../javascript/src/functions/providers/soprano.js), +[Python `acquire_token`](../python/src/providers/soprano.py), and +[.NET `AcquireTokenAsync`](../dotnet/Src/Providers/SopranoProvider.cs). All three use Azure Identity +`ManagedIdentityCredential` plus `ClientAssertionCredential` and reuse both so the SDK handles token caching +and refresh. Changing the provider tenant, calling application or managed identity creates new credentials. Each request passes its +configured scope to the SDK, so cached tokens cannot be reused for a different resource. Tokens +must have more than 30 seconds of remaining lifetime. No disk token cache is enabled, and the +application does not create service principals or credentials. Azure manages identity credentials. + +JavaScript SDK calls in each stage receive a 2.5-second cancellation signal; .NET passes its +2.5-second cancellation token into both stages. Configurable SDK transport +retries are disabled. Python uses 2.5-second connect/read inactivity timeouts, not a total wall-clock +deadline. Managed-identity discovery can involve additional SDK operations. Provider-key lookup and +the provider send have separate timeout behavior; there is no end-to-end 2.5-second guarantee. +Cold-start and uncached identity latency must be measured before production use. JavaScript suppresses +Azure SDK log output only in the asynchronous token-request context. Python applies a context-local +Azure Identity/Core/MSAL filter to configured logging handlers, including SDK-specific handlers. +Both preserve unrelated requests' logs; .NET disables credential diagnostics. Configure logging sinks +before handling requests. Keep platform/proxy body tracing disabled; never log tokens, credential +exceptions, or provider bodies. + +**Entra issues and signs the JWT; the Function does not create or sign it.** The Function checks for a +nonempty token and usable expiry metadata. It treats the access token as opaque, without custom +JWT parsing, alphabet checks, or regex. The SDK obtains tokens through Azure's managed-identity +mechanism, so the Function does not need a second inbound-style JWT validator for them. +Soprano must validate signature, issuer, audience, expiry, and caller claims at its boundary. +If Soprano rejects the combined credentials, the Function does not resend with API keys alone. +API-key fallback occurs only before the one provider submission when no usable token was acquired. + +The supplied QA4 guide describes an Entra ID v2.0 application token with: + +| Claim / token request | QA4 requirement | +|---|---| +| `aud` | `32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe` | +| `iss` | `https://login.microsoftonline.com/{tenantId}/v2.0` for the onboarded tenant | +| `azp` | The calling app's `EPP_PROVIDER_APPLICATION_ID`, authorized by Soprano for the intended account; not the managed identity's Client ID | +| Scope requested by the Function | `32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default` | + +Configure `EPP_PROVIDER_ENDPOINT=https://qa4.devops.sopranodesign.com/cgpapi`; the adapter appends +`/messages/omnimsg`. Use an account allowed to send both credentials. The guide does not establish +whether Soprano accepts API keys alone on every account, or which identity takes precedence when +both are present. Verify that behavior with Soprano before enabling the flag. The resource app controls +the access-token version; requesting `/.default` does not guarantee a v2 token. Agree on the issuer, +audience, token version, calling Application ID, API app-role assignments, and provider-side +account mapping with Soprano. Key Vault RBAC grants do not grant application permissions to the API. + +**Federated trust is required.** The calling app registration and user-assigned identity must share +the same home tenant. The app's federated credential trusts that tenant's v2 issuer, the identity's +Object (principal) ID as subject, and `api://AzureADTokenExchange` as audience. For a different provider +tenant, the calling app must be multitenant and its service principal provisioned and authorized there. +This exchange does not require the provider API to be provisioned in the identity's home tenant. +System-assigned identities are not supported as the federated credential in this documented flow. +See [Microsoft's federation guidance](https://learn.microsoft.com/entra/workload-id/workload-identity-federation-config-app-trust-managed-identity). +The earlier live client-secret tests do not verify this flow. New live verification must demonstrate +token attachment, the expected issuer/audience/Application ID, and provider acceptance after onboarding. + +Service-principal-per-customer provisioning remains an onboarding decision to agree with Soprano; +this code does not create service principals or assume that `azp` identifies a Marketplace purchase. +Marketplace subscription ID is not a standard Entra access-token claim. Do not substitute the Azure +subscription ID or tenant ID. Agree on an explicit provider-side account/subscription mapping or +supported claim extension before relying on JWTs for metered billing. + JWE provides payload confidentiality and integrity, **not SAS caller authentication**. Anyone with the public key can encrypt a request. The nonce acknowledges decryption; it is not an authentication credential or replay protection, and a fixed nonce cannot substitute for Easy Auth. @@ -221,10 +313,15 @@ Set by provisioning. **Identical names across all languages.** | `EPP_PROVIDER_ENDPOINT` | absolute HTTPS base URL with a hostname, port 1–65535, and no userinfo or fragment; the final adapter URL is also validated; redirects are not followed | | `EPP_PROVIDER_ACCOUNT_NAME` | sender/source only when required by the selected adapter | | `EPP_PROVIDER_TIMEOUT_MS` | trimmed ASCII decimal milliseconds; default 1500 for missing/invalid/nonpositive values; capped at 2500. Not a whole-invocation deadline | +| `EPP_PROVIDER_JWT_ENABLED` | Optional, default off. Soprano only: exchange a managed-identity assertion for a provider-tenant application token when `true`; API-ID/key headers remain mandatory | +| `EPP_PROVIDER_TENANT_ID` | Provider/resource tenant for the final application token | +| `EPP_PROVIDER_APPLICATION_ID` | Application (client) ID of the calling app registration, not the provider API's ID | +| `EPP_PROVIDER_MI_CLIENT_ID` | Client ID of the attached user-assigned managed identity trusted by the calling app's federated credential; distinct from its Object (principal) ID | +| `EPP_PROVIDER_SCOPE` | Required for JWT acquisition. Provider API Application ID or Application ID URI plus `/.default`; no default | | `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 (provider API keys) | -| `AZURE_CLIENT_ID` | set for a user-assigned managed identity | +| `AZURE_CLIENT_ID` | Optional Client ID of the attached user-assigned identity for Key Vault only; empty/unset uses system-assigned identity. Independent of `EPP_PROVIDER_MI_CLIENT_ID` | Provider credential values live in **Key Vault**, under the names in the selected adapter's manifest, and are fetched via **managed identity** with the *Key Vault Secrets User* role. Do not put credential @@ -271,6 +368,9 @@ subscription activation and changing tenant policy belong to provisioning, not t correlation ID's SHA256 hash, HTTP status, elapsed milliseconds and evaluation flag. Original wire correlation IDs and the required nonce echo remain unchanged. Hashes are pseudonymous, not anonymous; restrict log access and retention. + Before a live Soprano submission, the engine also logs `SopranoAuth=api-key` or + `SopranoAuth=api-key+jwt` with the same correlation hash, based on the actual outgoing headers. + This distinguishes token attachment from API-key fallback; it does not expose token values or claims. A configured encryption-key-ID mismatch adds a fixed warning, never either key ID or the JWE header. Disable SDK, platform and proxy body tracing separately. - **Platform authentication only** — enable Easy Auth with `requireAuthentication=true`, diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index a10d438..719d07b 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -17,6 +17,33 @@ in code or app settings. Grant the Function's managed identity *Key Vault Secret appropriate secret or vault scope. Confirm that the endpoint and credentials belong to the same account and environment. Individual API contracts stay in the adapters. +For optional Soprano JWT authentication, the **Function requests the token from Entra**; SAS does +not provide it in the JWE. It exchanges a managed-identity assertion, with no application client secret: + +1. Attach the trusted user-assigned identity and set `EPP_PROVIDER_MI_CLIENT_ID` to its Client ID. + Keep the existing Key Vault identity (`AZURE_CLIENT_ID` or system-assigned) and API ID/key secrets. +2. Configure or reuse a federated credential on the calling app registration in the identity's home + tenant. Trust its v2 issuer, identity Object (principal) ID as subject, and `api://AzureADTokenExchange` + as audience. The calling app must be multitenant and authorized in a different provider tenant. +3. Set `EPP_PROVIDER_TENANT_ID` to the provider tenant and `EPP_PROVIDER_APPLICATION_ID` to the calling + app's Application ID. Set `EPP_PROVIDER_SCOPE` to the provider API's Application ID or URI plus `/.default`. + QA4 uses `32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default`; the code has no default scope. +4. Set `EPP_PROVIDER_JWT_ENABLED=true` to enable acquisition. Leave it off for API-key-only delivery. + Missing scope or token failure falls back to API keys, so confirm this policy with Soprano and + verify the request's `SopranoAuth=api-key+jwt` log during live JWT testing. + +The first token is the managed-identity assertion for `api://AzureADTokenExchange/.default`. The second +is the application token issued in the provider tenant. Only that second token is sent to Soprano. +The provider must accept the calling application's identity; Key Vault RBAC is unrelated to this trust. + +These settings go in the Azure Function App environment or `Values` in private local settings. +There is no additional Key Vault secret for JWT acquisition. Never store an access token in settings. +`AZURE_CLIENT_ID` selects Key Vault's identity; `EPP_PROVIDER_MI_CLIENT_ID` selects federation's identity. Locally, mock +the identity SDK for offline tests; CLI login cannot substitute for an Azure managed-identity endpoint. +An injected local API-key resolver alone does not enable JWT acquisition. +See the [provider JWT contract](CONTRACT.md#optional-soprano-provider-jwt) for token flow, caching, +fallback, QA4 claims, and the account/billing questions still to agree with Soprano. + ### Setup script compatibility The Preview 1 setup script creates the encryption-key secret, not the selected provider's API diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index 6071ed5..97ff384 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -1,5 +1,4 @@ { - "_comment": "Local template: copy beside the chosen app's host.json and replace placeholders. Change node to python or dotnet-isolated for those runtimes. Start Azurite for UseDevelopmentStorage=true. Provider credentials stay in Key Vault. Local hosts have no Easy Auth; keep them on loopback. See README.md for Azure settings and optional values.", "IsEncrypted": false, "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", @@ -11,6 +10,13 @@ "EPP_PROVIDER_ENDPOINT": "https:///", "EPP_PROVIDER_TIMEOUT_MS": "1500", - "KEY_VAULT_URL": "https://.vault.azure.net/" + "EPP_PROVIDER_JWT_ENABLED": "false", + "EPP_PROVIDER_TENANT_ID": "", + "EPP_PROVIDER_APPLICATION_ID": "", + "EPP_PROVIDER_MI_CLIENT_ID": "", + "EPP_PROVIDER_SCOPE": "/.default", + + "KEY_VAULT_URL": "https://.vault.azure.net/", + "AZURE_CLIENT_ID": "" } } diff --git a/dotnet/README.md b/dotnet/README.md index 2a45212..a12cfc9 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -51,6 +51,13 @@ Add `EPP_PROVIDER_ACCOUNT_NAME` and adapter-specific options only when required. under the adapter manifest's Key Vault secret names, not in local settings. See the [complete variable table](../README.md#configure-environment-variables). +Optional Soprano JWT acquisition uses `ManagedIdentityCredential` and `ClientAssertionCredential`, not an application secret. +Set `EPP_PROVIDER_SCOPE` to the provider API's Application ID or URI plus `/.default`, and enable +`EPP_PROVIDER_JWT_ENABLED` only after provider authorization. Configure `EPP_PROVIDER_TENANT_ID`, +`EPP_PROVIDER_APPLICATION_ID`, and `EPP_PROVIDER_MI_CLIENT_ID` for the federated exchange. +`AZURE_CLIENT_ID` remains independent for Key Vault. See [JWT setup](../README.md#soprano-jwt-setup) +for tenant requirements. Local tests mock the identity SDK; CLI login is not a token fallback. + Core Tools loads `Values` into environment variables. [AppConfig.Read](Src/AppConfig.cs) reads them through `IEnv`; direct worker execution and unit tests do not automatically load local settings. Restart the host after edits. Configure local host storage other than Azurite separately; do not copy diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index fdf5cec..3b8f18a 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -217,12 +217,16 @@ public sealed class DispatchEngine private readonly IHttpClientFactory _httpFactory; private readonly IEnv _env; - public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null) + private readonly Microsoft.Extensions.Logging.ILogger? _logger; + + public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null, + Microsoft.Extensions.Logging.ILogger? logger = null) { _registry = registry; _secrets = secrets; _httpFactory = httpFactory; _env = env ?? new ProcessEnv(); + _logger = logger; } public async Task DispatchAsync(DispatchRequest dispatch, string requestId) @@ -259,6 +263,8 @@ public async Task DispatchAsync(DispatchRequest dispatch, string if (!IsHttpsEndpoint(endpoint)) return new DispatchResult(502, FailBody(providerId, channel, "provider endpoint invalid or not configured", dispatch, requestId)); + credential = credential with { Token = await adapter.AcquireTokenAsync(_env) }; + var timeoutMs = NormalizeProviderTimeoutMs(config.ProviderTimeoutMs); try { @@ -266,6 +272,14 @@ public async Task DispatchAsync(DispatchRequest dispatch, string if (!IsHttpsEndpoint(req.Url)) return new DispatchResult(502, FailBody(providerId, channel, "provider request endpoint invalid", dispatch, requestId)); + if (providerId == "soprano" && _logger is not null) + { + var correlationHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(dispatch.CorrelationId ?? "")))[..16].ToLowerInvariant(); + Microsoft.Extensions.Logging.LoggerExtensions.LogInformation(_logger, + "[EPP] SopranoAuth={AuthMode} CorrelationId={CorrelationId}", + req.Headers.ContainsKey("Authorization") ? "api-key+jwt" : "api-key", correlationHash); + } + var (providerHttpStatus, success, body) = await SendAsync(req, timeoutMs); JsonElement json; try { using var responseDocument = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); json = responseDocument.RootElement.Clone(); } diff --git a/dotnet/Src/IProviderAdapter.cs b/dotnet/Src/IProviderAdapter.cs index 1d4a7dc..a012131 100644 --- a/dotnet/Src/IProviderAdapter.cs +++ b/dotnet/Src/IProviderAdapter.cs @@ -6,6 +6,8 @@ public interface IProviderAdapter { ProviderManifest Manifest { get; } + Task AcquireTokenAsync(IEnv env) => Task.FromResult(null); + ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env); ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json); diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index e4beeb6..d5526bc 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -39,7 +39,11 @@ public sealed record TextToVoice( public override string ToString() => nameof(TextToVoice); } -public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null); +public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, + [property: JsonIgnore] string? Token = null) +{ + public override string ToString() => nameof(ProviderCredential); +} public sealed record ProviderHttpRequest(string Url, string Method, Dictionary Headers, string Body); diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index a65fdea..35ffd78 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -1,9 +1,80 @@ using System.Text.Json; +using Azure.Core; +using Azure.Identity; namespace Epp.Otp.Providers; public sealed class SopranoProvider : IProviderAdapter { + private readonly Func _createManagedIdentity; + private readonly Func>, TokenCredential> _createCredential; + private readonly object _credentialLock = new(); + private TokenCredential? _credential; + private (string Tenant, string Application, string Identity) _credentialSettings; + + public SopranoProvider() : this(identity => new ManagedIdentityCredential(identity, CredentialOptions()), + (tenant, applicationId, assertion) => new ClientAssertionCredential(tenant, applicationId, assertion, CredentialOptions())) { } + + private static ClientAssertionCredentialOptions CredentialOptions() + { + var options = new ClientAssertionCredentialOptions { AuthorityHost = AzureAuthorityHosts.AzurePublicCloud }; + options.Retry.MaxRetries = 0; + options.Retry.NetworkTimeout = TimeSpan.FromSeconds(2.5); + options.Diagnostics.IsLoggingEnabled = false; + options.Diagnostics.IsLoggingContentEnabled = false; + return options; + } + + internal SopranoProvider(Func createManagedIdentity, + Func>, TokenCredential> createCredential) + { + _createManagedIdentity = createManagedIdentity; + _createCredential = createCredential; + } + + private static bool JwtEnabled(IEnv env) => + string.Equals(env.Get("EPP_PROVIDER_JWT_ENABLED")?.Trim(), "true", StringComparison.OrdinalIgnoreCase); + + public async Task AcquireTokenAsync(IEnv env) + { + if (!JwtEnabled(env)) return null; + var scope = env.Get("EPP_PROVIDER_SCOPE")?.Trim(); + var tenant = env.Get("EPP_PROVIDER_TENANT_ID")?.Trim(); + var applicationId = env.Get("EPP_PROVIDER_APPLICATION_ID")?.Trim(); + var identity = env.Get("EPP_PROVIDER_MI_CLIENT_ID")?.Trim(); + if (string.IsNullOrEmpty(scope) || string.IsNullOrEmpty(tenant) + || string.IsNullOrEmpty(applicationId) || string.IsNullOrEmpty(identity)) return null; + try + { + TokenCredential credential; + lock (_credentialLock) + { + var settings = (tenant, applicationId, identity); + if (_credential is null || _credentialSettings != settings) + { + var managedIdentity = _createManagedIdentity(identity); + _credential = _createCredential(tenant, applicationId, async cancellation => + { + var assertion = await managedIdentity.GetTokenAsync( + new TokenRequestContext(new[] { "api://AzureADTokenExchange/.default" }), cancellation); + if (assertion.ExpiresOn <= DateTimeOffset.UtcNow.AddSeconds(30) || string.IsNullOrWhiteSpace(assertion.Token)) + throw new InvalidOperationException("managed identity assertion unavailable"); + return assertion.Token; + }); + _credentialSettings = settings; + } + credential = _credential; + } + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2.5)); + var result = await credential.GetTokenAsync(new TokenRequestContext(new[] { scope }), cancellation.Token); + return result.ExpiresOn > DateTimeOffset.UtcNow.AddSeconds(30) && !string.IsNullOrWhiteSpace(result.Token) ? result.Token : null; + } + catch + { + return null; + } + } + public ProviderManifest Manifest { get; } = new( Id: "soprano", Auth: new AuthConfig("apiKey", KeyVaultSecretName: "soprano-api-key", IdentityKeyVaultSecretName: "soprano-api-id"), @@ -32,6 +103,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc ["X-MEMS-API-ID"] = credential.Identity ?? string.Empty, ["X-MEMS-API-Key"] = credential.Secret ?? string.Empty, }; + if (JwtEnabled(env) && !string.IsNullOrWhiteSpace(credential.Token)) headers["Authorization"] = "Bearer " + credential.Token; var body = new Dictionary { ["destination"] = dispatch.Destination.TrimStart('+'), diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index c742349..b03ced3 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Azure.Core; using Epp.Otp.Providers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -18,6 +19,7 @@ public class EngineTests private const string Kid = "private-jwe-kid"; private const string Correlation = "private-correlation"; private const string PrivateError = "private key/provider error: +15551234567 code 918273"; + private const string ProviderToken = "eyJhbGciOiJSUzI1NiJ9.eyJ2ZXIiOiIyLjAifQ.c2lnbmF0dXJl"; [Fact] public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() @@ -81,6 +83,157 @@ public void VoiceAllowsEmptyIntroAndKeepsDebugOutputPrivate() Assert.Equal("TextToVoice", voice.ToString()); } + [Theory] + [InlineData("sms", "true")] + [InlineData("voice", " TRUE ")] + public async Task OptionalProviderJwtKeepsApiKeysAndStaysOutOfBodyAndLogs(string channel, string? flag) + { + using var rig = new HandlerRig(); + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = flag; + var changes = JsonSerializer.SerializeToElement(new { providerJwt = "FORGED-PAYLOAD", + textToVoice = new { beforePasswordText = "Code", password = "001234", language = "en-US" } }); + AssertAccepted(await rig.Invoke(channel: channel, deliveryOverrides: changes)); + Assert.Equal("private-api-id", rig.Http.Headers["X-MEMS-API-ID"]); + Assert.Equal("private-api-key", rig.Http.Headers["X-MEMS-API-Key"]); + Assert.Equal("Bearer " + ProviderToken, rig.Http.Headers["Authorization"]); + Assert.DoesNotContain(ProviderToken, rig.Http.Body! + string.Join("", rig.Log.Messages)); + Assert.DoesNotContain("FORGED-PAYLOAD", rig.Http.Body!); + Assert.DoesNotContain("FORGED-INBOUND", JsonSerializer.Serialize(rig.Http.Headers)); + var context = DeliveryContext.FromPayload(changes); + Assert.DoesNotContain("FORGED-PAYLOAD", JsonSerializer.Serialize(context)); + var credential = new ProviderCredential("apiKey", "key", "id", ProviderToken); + Assert.DoesNotContain(ProviderToken, credential.ToString() + JsonSerializer.Serialize(credential)); + if (rig.Tokens.Calls > 0) + { + Assert.Equal(rig.Env["EPP_PROVIDER_SCOPE"], Assert.Single(rig.Tokens.Scopes!)); + Assert.True(rig.Tokens.HasCancellation); + } + Assert.Equal(2, rig.Secrets.Calls); + } + + [Fact] + public async Task DisabledJwtIgnoresInboundTokenAndOtherProviders() + { + using var rig = new HandlerRig(); + foreach (var flag in new string?[] { null, "false", "1", "yes" }) + { + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = flag; + AssertAccepted(await rig.Invoke(deliveryOverrides: JsonSerializer.SerializeToElement(new { providerJwt = "not-a-jwt" }))); + Assert.False(rig.Http.Headers.ContainsKey("Authorization")); + } + rig.Env["EPP_PROVIDER_NAME"] = "infobip"; + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = "true"; + rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"messages\":[{\"status\":{\"groupName\":\"PENDING\"}}]}")); + AssertAccepted(await rig.Invoke(deliveryOverrides: JsonSerializer.SerializeToElement(new { providerJwt = "not-a-jwt" }))); + Assert.Equal("App private-api-key", rig.Http.Headers["Authorization"]); + Assert.DoesNotContain("not-a-jwt", rig.Http.Body!); + Assert.Equal(0, rig.Tokens.Calls); + } + + [Fact] + public async Task ManagedIdentitySelectionReusesCredentialsWithoutClientSecrets() + { + using var rig = new HandlerRig(); + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = "true"; + AssertAccepted(await rig.Invoke()); + Assert.Equal(rig.Env["EPP_PROVIDER_MI_CLIENT_ID"], Assert.Single(rig.TokenIdentities)); + Assert.Equal("api://AzureADTokenExchange/.default", Assert.Single(rig.Assertions.Scopes!)); + Assert.True(rig.Assertions.HasCancellation); + AssertAccepted(await rig.Invoke()); + Assert.Single(rig.TokenIdentities); + rig.Env["EPP_PROVIDER_MI_CLIENT_ID"] = "44444444-4444-4444-8444-444444444444"; + AssertAccepted(await rig.Invoke()); + Assert.Equal(2, rig.TokenIdentities.Count); + Assert.Equal(rig.Env["EPP_PROVIDER_MI_CLIENT_ID"], rig.TokenIdentities[1]); + rig.Env["EPP_PROVIDER_SCOPE"] = "api://another-provider/.default"; + AssertAccepted(await rig.Invoke()); + Assert.Equal(2, rig.TokenIdentities.Count); + Assert.Equal(rig.Env["EPP_PROVIDER_SCOPE"], Assert.Single(rig.Tokens.Scopes!)); + Assert.Equal(8, rig.Secrets.Calls); + Assert.DoesNotContain("private-exchange-assertion", rig.Http.Body! + string.Join("", rig.Http.Headers.Values) + string.Join("", rig.Log.Messages)); + } + + [Fact] + public async Task MissingFederationSettingsAndUnavailableAssertionsNeverAttachAToken() + { + using var rig = new HandlerRig(); + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = "true"; + foreach (var name in new[] { "EPP_PROVIDER_SCOPE", "EPP_PROVIDER_TENANT_ID", "EPP_PROVIDER_APPLICATION_ID", "EPP_PROVIDER_MI_CLIENT_ID" }) + { + var saved = rig.Env[name]; + rig.Env[name] = " "; + AssertAccepted(await rig.Invoke()); + Assert.False(rig.Http.Headers.ContainsKey("Authorization")); + rig.Env[name] = saved; + } + Assert.Empty(rig.TokenIdentities); + rig.Assertions.Token = ""; + AssertAccepted(await rig.Invoke()); + Assert.False(rig.Http.Headers.ContainsKey("Authorization")); + rig.Assertions.Token = "private-exchange-assertion"; + rig.Assertions.ExpiresOn = DateTimeOffset.UtcNow.AddSeconds(-1); + AssertAccepted(await rig.Invoke()); + Assert.False(rig.Http.Headers.ContainsKey("Authorization")); + rig.Assertions.Error = new InvalidOperationException("PRIVATE-ASSERTION-ERROR"); + AssertAccepted(await rig.Invoke()); + Assert.False(rig.Http.Headers.ContainsKey("Authorization")); + Assert.DoesNotContain("PRIVATE-ASSERTION-ERROR", string.Join("", rig.Log.Messages)); + } + + [Fact] + public async Task UnavailableAcquiredTokensFallBackAndEvaluationSkipsEntra() + { + using var rig = new HandlerRig(); + foreach (var token in new[] { "", " " }) + { + rig.Tokens.Token = token; + var changes = JsonSerializer.SerializeToElement(new { providerJwt = ProviderToken }); + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = "true"; + var calls = rig.Http.Calls; + var secretCalls = rig.Secrets.Calls; + var tokenCalls = rig.Tokens.Calls; + AssertAccepted(await rig.Invoke("evaluation", deliveryOverrides: changes)); + Assert.Equal(secretCalls, rig.Secrets.Calls); + Assert.Equal(tokenCalls, rig.Tokens.Calls); + AssertAccepted(await rig.Invoke(deliveryOverrides: changes)); + Assert.Equal(calls + 1, rig.Http.Calls); + Assert.False(rig.Http.Headers.ContainsKey("Authorization")); + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = "false"; + AssertAccepted(await rig.Invoke(deliveryOverrides: changes)); + Assert.False(rig.Http.Headers.ContainsKey("Authorization")); + } + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = "true"; + rig.Tokens.Token = "opaque-access-token-from-entra"; + AssertAccepted(await rig.Invoke()); + Assert.Equal("Bearer opaque-access-token-from-entra", rig.Http.Headers["Authorization"]); + rig.Tokens.Token = ProviderToken; + rig.Tokens.ExpiresOn = DateTimeOffset.UtcNow.AddSeconds(-10); + AssertAccepted(await rig.Invoke()); + Assert.False(rig.Http.Headers.ContainsKey("Authorization")); + rig.Tokens.Error = new InvalidOperationException("PRIVATE-TOKEN-ERROR"); + AssertAccepted(await rig.Invoke()); + Assert.DoesNotContain("PRIVATE-TOKEN-ERROR", string.Join("", rig.Log.Messages)); + } + + [Fact] + public async Task ProviderJwtCannotReplaceMissingKeysAndAuthFailureDoesNotRetry() + { + using var rig = new HandlerRig(); + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = "true"; + var changes = JsonSerializer.SerializeToElement(new { providerJwt = ProviderToken }); + rig.Secrets.Identity = ""; + AssertFailure(rig, await rig.Invoke(deliveryOverrides: changes), 502); + rig.Secrets.Identity = "private-api-id"; + rig.Secrets.Secret = ""; + AssertFailure(rig, await rig.Invoke(deliveryOverrides: changes), 502); + Assert.Equal(0, rig.Http.Calls); + rig.Secrets.Secret = "private-api-key"; + rig.Http.Respond = _ => Task.FromResult(Json(401, "{\"status\":\"REJECTED\"}")); + AssertFailure(rig, await rig.Invoke(deliveryOverrides: changes), 401); + Assert.Equal(1, rig.Http.Calls); + Assert.DoesNotContain(ProviderToken, string.Join("", rig.Log.Messages)); + } + [Fact] public async Task FailedHttpCannotAcknowledgeAnAcceptedBodyOrLeakProviderText() { @@ -264,6 +417,9 @@ private sealed class HandlerRig : IDisposable public TestSecrets Secrets { get; } = new(); public TestHttp Http { get; } = new(); public TestKeys Keys { get; } = new(); + public TestTokenCredential Tokens { get; } = new(); + public TestTokenCredential Assertions { get; } = new() { Token = "private-exchange-assertion" }; + public List TokenIdentities { get; } = new(); public CapturingLogger Log { get; } = new(); public HandlerRig() { @@ -272,9 +428,23 @@ public HandlerRig() ["EPP_PROVIDER_NAME"] = "soprano", ["EPP_PROVIDER_ENDPOINT"] = "https://provider.example/cgpapi", ["EPP_PROVIDER_TIMEOUT_MS"] = "2500", + ["EPP_PROVIDER_SCOPE"] = "api://provider-application-id/.default", + ["EPP_PROVIDER_TENANT_ID"] = "11111111-1111-4111-8111-111111111111", + ["EPP_PROVIDER_APPLICATION_ID"] = "22222222-2222-4222-8222-222222222222", + ["EPP_PROVIDER_MI_CLIENT_ID"] = "33333333-3333-4333-8333-333333333333", }; var registry = new ProviderRegistry(new IProviderAdapter[] - { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() }); + { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(identity => + { + TokenIdentities.Add(identity); + return Assertions; + }, (tenant, applicationId, assertion) => + { + Assert.Equal(Env["EPP_PROVIDER_TENANT_ID"], tenant); + Assert.Equal(Env["EPP_PROVIDER_APPLICATION_ID"], applicationId); + Tokens.GetAssertion = assertion; + return Tokens; + }), new SinchProvider() }); _function = new SendOtp(new DispatchEngine(registry, Secrets, Http, Env), new JweDecryptor(Keys), Env, Log); } @@ -301,11 +471,36 @@ public async Task InvokeRaw(string body) request.Method = "POST"; request.ContentType = "application/json"; request.Body = stream; + request.Headers.Authorization = "Bearer FORGED-INBOUND"; return Assert.IsAssignableFrom(await _function.Run(request)); } public void Dispose() { Keys.Dispose(); Http.Dispose(); } } + private sealed class TestTokenCredential : TokenCredential + { + public int Calls { get; private set; } + public string Token { get; set; } = ProviderToken; + public DateTimeOffset ExpiresOn { get; set; } = DateTimeOffset.UtcNow.AddHours(1); + public string[]? Scopes { get; private set; } + public bool HasCancellation { get; private set; } + public Exception? Error { get; set; } + public Func>? GetAssertion { get; set; } + public override AccessToken GetToken(TokenRequestContext context, CancellationToken cancellation) + { + Calls++; + Scopes = context.Scopes; + HasCancellation = cancellation.CanBeCanceled; + if (Error is not null) throw Error; + return new AccessToken(Token, ExpiresOn); + } + public override async ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken cancellation) + { + if (GetAssertion is not null) Assert.Equal("private-exchange-assertion", await GetAssertion(cancellation)); + return GetToken(context, cancellation); + } + } + private sealed class TestSecrets : ISecretResolver { public int Calls { get; private set; } diff --git a/javascript/README.md b/javascript/README.md index 293e006..3bcd264 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -51,6 +51,13 @@ Add `EPP_PROVIDER_ACCOUNT_NAME` and any adapter-specific options only when requi `EPP_PROVIDER_TIMEOUT_MS` is a string such as `"1500"`. Replace placeholders; do not put API keys in this file. See the [complete variable table](../README.md#configure-environment-variables). +Optional Soprano JWT acquisition uses `ManagedIdentityCredential` and `ClientAssertionCredential`, not an application secret. +Set `EPP_PROVIDER_SCOPE` to the provider API's Application ID or URI plus `/.default`, and enable +`EPP_PROVIDER_JWT_ENABLED` only after provider authorization. Configure `EPP_PROVIDER_TENANT_ID`, +`EPP_PROVIDER_APPLICATION_ID`, and `EPP_PROVIDER_MI_CLIENT_ID` for the federated exchange. +`AZURE_CLIENT_ID` remains independent for Key Vault. See [JWT setup](../README.md#soprano-jwt-setup) +for tenant requirements. Local tests mock the identity SDK; CLI login is not a token fallback. + Core Tools copies `Values` into the process environment; direct Node processes and the offline tests do **not** automatically load this file. [AppConfig](src/functions/config.js) reads `process.env` once per call to `readConfig()`. Restart the host after changing settings. Configure any local host diff --git a/javascript/package-lock.json b/javascript/package-lock.json index 70a563b..20db06d 100644 --- a/javascript/package-lock.json +++ b/javascript/package-lock.json @@ -11,6 +11,7 @@ "@azure/functions": "^4.0.0", "@azure/identity": "^4.13.1", "@azure/keyvault-secrets": "^4.11.2", + "@azure/logger": "1.3.0", "jose": "^5.9.6" } }, diff --git a/javascript/package.json b/javascript/package.json index bacac39..88a5420 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -11,6 +11,7 @@ "@azure/functions": "^4.0.0", "@azure/identity": "^4.13.1", "@azure/keyvault-secrets": "^4.11.2", + "@azure/logger": "1.3.0", "jose": "^5.9.6" } } diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index c207de3..1d88fa0 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -72,7 +72,7 @@ app.http('SendOtp', { if (!evaluation) { const dispatch = contextToDispatch(delivery, envelope, clientRequestId); dispatch.correlationId = correlationId; - const result = await dispatchOtp(dispatch, { requestId, config }).catch(() => ({ httpStatus: 500 })); + const result = await dispatchOtp(dispatch, { requestId, config, log: message => context.log(message) }).catch(() => ({ httpStatus: 500 })); if (result.httpStatus !== 200) { return respond(result.httpStatus, { error: 'provider_delivery_failed', correlationId, requestId }); } diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index 0f96293..43c67cb 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -330,18 +330,33 @@ async function sendViaProvider(providerEntry, dispatch, options) { return { httpStatus: 502, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; } - const providerRequest = adapter.buildRequest({ - channel, - endpoint: endpointBaseUrl, - dispatch, - credential, - env: config.env, - }); + if (adapter.acquireToken) { + const token = await adapter.acquireToken(config.env); + Object.defineProperty(credential, 'token', { value: token }); + } + + let providerRequest; + try { + providerRequest = adapter.buildRequest({ + channel, + endpoint: endpointBaseUrl, + dispatch, + credential, + env: config.env, + }); + } catch { + return { httpStatus: 502, body: failBody(providerId, channel, 'provider request failed', dispatch, requestId) }; + } if (!isValidProviderUrl(providerRequest.url)) { return { httpStatus: 502, body: failBody(providerId, channel, 'provider request URL invalid', dispatch, requestId) }; } + if (providerId === 'soprano') { + const correlationHash = crypto.createHash('sha256').update(String(dispatch.correlationId || '')).digest('hex').slice(0, 16); + options.log?.(`[EPP] SopranoAuth=${providerRequest.headers.Authorization ? 'api-key+jwt' : 'api-key'} CorrelationId=${correlationHash}`); + } + const timeoutMilliseconds = parseProviderTimeout(config.providerTimeoutMs); let providerResponse; let responseText; @@ -382,7 +397,7 @@ async function sendViaProvider(providerEntry, dispatch, options) { }; } -async function dispatchOtp(dispatch, { config = readConfig(), requestId } = {}) { +async function dispatchOtp(dispatch, { config = readConfig(), requestId, log } = {}) { const providerEntry = getProvider(config.providerName); if (!providerEntry) { return { @@ -390,7 +405,7 @@ async function dispatchOtp(dispatch, { config = readConfig(), requestId } = {}) body: { status: 'error', reason: 'unknown provider', requestId }, }; } - return sendViaProvider(providerEntry, dispatch, { config, requestId }); + return sendViaProvider(providerEntry, dispatch, { config, requestId, log }); } module.exports = { diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index 8696c50..62bacdc 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -5,6 +5,58 @@ 'use strict'; const { ParsedResponse, TextToVoice } = require('../models'); +const { ManagedIdentityCredential, ClientAssertionCredential } = require('@azure/identity'); +const { AzureLogger } = require('@azure/logger'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +let tokenCredential; +let tokenCredentialSettings; +const tokenRequest = new AsyncLocalStorage(); +let filteredLogger; + +function jwtEnabled(env) { + return typeof env?.EPP_PROVIDER_JWT_ENABLED === 'string' + && env.EPP_PROVIDER_JWT_ENABLED.trim().toLowerCase() === 'true'; +} + +async function acquireToken(env) { + if (!jwtEnabled(env)) return ''; + const scope = typeof env.EPP_PROVIDER_SCOPE === 'string' ? env.EPP_PROVIDER_SCOPE.trim() : ''; + const settings = [env.EPP_PROVIDER_TENANT_ID, env.EPP_PROVIDER_APPLICATION_ID, env.EPP_PROVIDER_MI_CLIENT_ID] + .map(value => typeof value === 'string' ? value.trim() : ''); + if (!scope || settings.some(value => !value)) return ''; + if (AzureLogger.log !== filteredLogger) { + const log = AzureLogger.log; + filteredLogger = (...args) => { if (!tokenRequest.getStore()) log(...args); }; + AzureLogger.log = filteredLogger; + } + return tokenRequest.run(true, async () => { + try { + if (!tokenCredential || settings.some((value, index) => value !== tokenCredentialSettings[index])) { + const [tenant, applicationId, managedIdentityId] = settings; + const managedIdentity = new ManagedIdentityCredential({ + clientId: managedIdentityId, retryOptions: { maxRetries: 0 }, + }); + tokenCredential = new ClientAssertionCredential(tenant, applicationId, async () => { + const assertion = await managedIdentity.getToken('api://AzureADTokenExchange/.default', { + abortSignal: AbortSignal.timeout(2500), + }); + if (!assertion || assertion.expiresOnTimestamp <= Date.now() + 30000 + || typeof assertion.token !== 'string' || !assertion.token.trim()) { + throw new Error('managed identity assertion unavailable'); + } + return assertion.token; + }, { authorityHost: 'https://login.microsoftonline.com', retryOptions: { maxRetries: 0 } }); + tokenCredentialSettings = settings; + } + const result = await tokenCredential.getToken(scope, { abortSignal: AbortSignal.timeout(2500) }); + return result && result.expiresOnTimestamp > Date.now() + 30000 + && typeof result.token === 'string' && result.token.trim() ? result.token : ''; + } catch { + return ''; + } + }); +} const manifest = { id: 'soprano', @@ -29,7 +81,7 @@ const manifest = { }, }; -function buildRequest({ channel, endpoint, dispatch, credential }) { +function buildRequest({ channel, endpoint, dispatch, credential, env }) { let base = endpoint; while (base.endsWith('/')) base = base.slice(0, -1); const headers = { @@ -38,6 +90,7 @@ function buildRequest({ channel, endpoint, dispatch, credential }) { 'X-MEMS-API-ID': credential.identity, 'X-MEMS-API-Key': credential.secret, }; + if (jwtEnabled(env) && typeof credential.token === 'string' && credential.token.trim()) headers.Authorization = `Bearer ${credential.token}`; let destination = String(dispatch.destination || ''); while (destination.startsWith('+')) destination = destination.slice(1); const body = { @@ -68,4 +121,4 @@ function parseResponse({ httpStatus, ok, json }) { }); } -module.exports = { manifest, buildRequest, parseResponse }; +module.exports = { manifest, buildRequest, parseResponse, acquireToken }; diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 93ea084..4d29eb5 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -3,6 +3,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { SecretClient } = require('@azure/keyvault-secrets'); +const { ManagedIdentityCredential, ClientAssertionCredential } = require('@azure/identity'); const { AppConfig, readConfig } = require('../src/functions/config'); const { DeliveryContext, TextToVoice, ParsedResponse } = require('../src/functions/models'); const fixtures = require('../../tests/fixtures/contract.json'); @@ -111,6 +112,89 @@ test('Soprano Voice sends structured speech with API-key headers only', () => { /incomplete voice context/); }); +test('Soprano exchanges a reused managed identity assertion in the provider tenant without secrets', async (t) => { + const { acquireToken } = getProvider('soprano').adapter; + const token = 'opaque-access-token-from-entra'; + const env = { EPP_PROVIDER_JWT_ENABLED: ' TRUE ', EPP_PROVIDER_SCOPE: 'api://provider-application-id/.default', + EPP_PROVIDER_TENANT_ID: '11111111-1111-4111-8111-111111111111', + EPP_PROVIDER_APPLICATION_ID: '22222222-2222-4222-8222-222222222222', + EPP_PROVIDER_MI_CLIENT_ID: '33333333-3333-4333-8333-333333333333' }; + const getAssertion = t.mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ + token: 'private-exchange-assertion', expiresOnTimestamp: Date.now() + 60000, + })); + const getToken = t.mock.method(ClientAssertionCredential.prototype, 'getToken', async function () { + assert.equal(await this.getAssertion(), 'private-exchange-assertion'); + return { token, expiresOnTimestamp: Date.now() + 60000 }; + }); + t.mock.method(SecretClient.prototype, 'getSecret', () => assert.fail('token acquisition must not read Key Vault')); + for (const flag of [undefined, 'false', '1', 'yes', true]) { + assert.equal(await acquireToken({ ...env, EPP_PROVIDER_JWT_ENABLED: flag }), ''); + } + for (const name of ['EPP_PROVIDER_SCOPE', 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_APPLICATION_ID', 'EPP_PROVIDER_MI_CLIENT_ID']) { + for (const value of [undefined, '', ' ']) assert.equal(await acquireToken({ ...env, [name]: value }), ''); + } + assert.equal(getToken.mock.callCount(), 0); + assert.equal(getAssertion.mock.callCount(), 0); + assert.equal(await acquireToken(env), token); + assert.equal(getToken.mock.calls[0].arguments[0], env.EPP_PROVIDER_SCOPE); + assert.ok(getToken.mock.calls[0].arguments[1].abortSignal instanceof AbortSignal); + const client = getToken.mock.calls[0].this; + assert.equal(client.tenantId, env.EPP_PROVIDER_TENANT_ID); + assert.equal(getAssertion.mock.calls[0].arguments[0], 'api://AzureADTokenExchange/.default'); + assert.ok(getAssertion.mock.calls[0].arguments[1].abortSignal instanceof AbortSignal); + assert.equal(await acquireToken(env), token); + assert.equal(getToken.mock.calls[1].this, client); + assert.equal(getAssertion.mock.calls[0].this, getAssertion.mock.calls[1].this); + await acquireToken({ ...env, EPP_PROVIDER_MI_CLIENT_ID: '44444444-4444-4444-8444-444444444444' }); + assert.notEqual(getToken.mock.calls[2].this, client); + await acquireToken({ ...env, EPP_PROVIDER_SCOPE: 'api://another-provider/.default' }); + assert.equal(getToken.mock.calls[3].arguments[0], 'api://another-provider/.default'); + for (const result of [null, { token: '', expiresOnTimestamp: Date.now() + 60000 }, + { token: 'private-exchange-assertion', expiresOnTimestamp: Date.now() - 1000 }]) { + getAssertion.mock.mockImplementation(async () => result); + assert.equal(await acquireToken(env), ''); + } + getAssertion.mock.mockImplementation(async () => { throw new Error('private assertion failure'); }); + assert.equal(await acquireToken(env), ''); + for (const result of [null, { token, expiresOnTimestamp: Date.now() - 1000 }, + ...[undefined, null, false, {}, '', ' '].map(token => ({ token, expiresOnTimestamp: Date.now() + 60000 }))]) { + getToken.mock.mockImplementation(async () => result); + assert.equal(await acquireToken(env), ''); + } + getToken.mock.mockImplementation(async () => { throw new Error('private Entra error'); }); + assert.equal(await acquireToken(env), ''); +}); + +test('Soprano token acquisition keeps SDK diagnostics private without muting other requests', async (t) => { + const { AzureLogger, createClientLogger, getLogLevel, setLogLevel } = require('@azure/logger'); + const originalLevel = getLogLevel(); + const output = []; + const sink = t.mock.method(AzureLogger, 'log', (...args) => output.push(inspect(args))); + const sdk = createClientLogger('identity'); + setLogLevel('verbose'); + t.mock.method(ClientAssertionCredential.prototype, 'getToken', async () => { + sdk.warning('PRIVATE SDK token error'); + await Promise.resolve(); + sdk.error('PRIVATE assertion details'); + throw new Error('PRIVATE acquisition error'); + }); + try { + const pending = getProvider('soprano').adapter.acquireToken({ EPP_PROVIDER_JWT_ENABLED: 'true', + EPP_PROVIDER_SCOPE: 'api://provider/.default', EPP_PROVIDER_TENANT_ID: '11111111-1111-4111-8111-111111111111', + EPP_PROVIDER_APPLICATION_ID: '22222222-2222-4222-8222-222222222222', + EPP_PROVIDER_MI_CLIENT_ID: '33333333-3333-4333-8333-333333333333' }); + sdk.warning('other-request-diagnostic'); + assert.equal(await pending, ''); + sdk.warning('after-token-request'); + assert.ok(output.some(value => value.includes('other-request-diagnostic'))); + assert.ok(output.some(value => value.includes('after-token-request'))); + assert.equal(output.some(value => value.includes('PRIVATE')), false); + } finally { + setLogLevel(originalLevel); + sink.mock.restore(); + } +}); + test('Soprano Voice validates decrypted speech before secret lookup or HTTP', async (t) => { const getSecret = t.mock.method(SecretClient.prototype, 'getSecret', () => assert.fail('unexpected secret lookup')); const fetchMock = t.mock.method(global, 'fetch', () => assert.fail('unexpected HTTP')); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index ea3987c..658eeb0 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -6,6 +6,7 @@ const crypto = require('node:crypto'); const Module = require('node:module'); const { CompactEncrypt } = require('jose'); const { SecretClient } = require('@azure/keyvault-secrets'); +const { ManagedIdentityCredential, ClientAssertionCredential } = require('@azure/identity'); const fixtures = require('../../tests/fixtures/contract.json'); // Capture the real handler; keys stay in memory and all external I/O is mocked. @@ -25,12 +26,16 @@ try { } const envKeys = ['EPP_ENCRYPTION_KEY_ID', 'AZURE_CLIENT_ID', 'EPP_PROVIDER_NAME', 'EPP_PROVIDER_ENDPOINT', - 'EPP_PROVIDER_TIMEOUT_MS', 'EPP_LOG_PLAINTEXT', 'KEY_VAULT_URL', 'EPP_DECRYPTION_KEY_PEM']; + 'EPP_PROVIDER_TIMEOUT_MS', 'EPP_PROVIDER_JWT_ENABLED', 'EPP_PROVIDER_SCOPE', + 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_APPLICATION_ID', 'EPP_PROVIDER_MI_CLIENT_ID', + 'EPP_LOG_PLAINTEXT', 'KEY_VAULT_URL', 'EPP_DECRYPTION_KEY_PEM']; let savedEnv; let fetchMock; let getSecret; let logs; let warnings; +let getToken; +const providerToken = 'eyJhbGciOiJSUzI1NiJ9.eyJ2ZXIiOiIyLjAifQ.c2lnbmF0dXJl'; beforeEach(() => { savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); for (const key of envKeys) delete process.env[key]; @@ -38,6 +43,19 @@ beforeEach(() => { EPP_DECRYPTION_KEY_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }), KEY_VAULT_URL: 'https://unit-test.vault.azure.net', EPP_PROVIDER_NAME: 'soprano', EPP_PROVIDER_ENDPOINT: 'https://provider.example/cgpapi/' }); + process.env.EPP_PROVIDER_SCOPE = 'api://provider-application-id/.default'; + Object.assign(process.env, { EPP_PROVIDER_TENANT_ID: '11111111-1111-4111-8111-111111111111', + EPP_PROVIDER_APPLICATION_ID: '22222222-2222-4222-8222-222222222222', + EPP_PROVIDER_MI_CLIENT_ID: '33333333-3333-4333-8333-333333333333' }); + mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ + token: 'PRIVATE-EXCHANGE-ASSERTION', expiresOnTimestamp: Date.now() + 3600000, + })); + getToken = mock.method(ClientAssertionCredential.prototype, 'getToken', async function () { + assert.equal(await this.getAssertion(), 'PRIVATE-EXCHANGE-ASSERTION'); + return { + token: providerToken, expiresOnTimestamp: Date.now() + 3600000, + }; + }); getSecret = mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'PRIVATE-API-KEY' })); fetchMock = mock.method(global, 'fetch', async () => ({ ok: true, status: 201, text: async () => JSON.stringify({ status: 'ENROUTE', id: 'PRIVATE-ID', description: 'PRIVATE-STATUS' }) })); @@ -179,9 +197,11 @@ test('SMS/voice preserve content and correlation without reflecting headers or l assert.deepEqual(init.headers, { 'Content-Type': 'application/json', Accept: 'application/json', 'X-MEMS-API-ID': 'PRIVATE-API-KEY', 'X-MEMS-API-Key': 'PRIVATE-API-KEY' }); assert.equal(init.redirect, 'manual'); - assert.equal(logs.length, 1); - assert.deepEqual(Object.keys(logs[0]).sort(), ['correlationId', 'elapsedMs', 'evaluation', 'httpStatus', 'requestId']); - assert.equal(logs[0].correlationId, crypto.createHash('sha256').update(correlationId).digest('hex').slice(0, 16)); + assert.equal(logs.length, 2); + const correlationHash = crypto.createHash('sha256').update(correlationId).digest('hex').slice(0, 16); + assert.equal(logs[0], `[EPP] SopranoAuth=api-key CorrelationId=${correlationHash}`); + assert.deepEqual(Object.keys(logs[1]).sort(), ['correlationId', 'elapsedMs', 'evaluation', 'httpStatus', 'requestId']); + assert.equal(logs[1].correlationId, correlationHash); assert.doesNotMatch(JSON.stringify(logs), /PRIVATE|918273|001234|15551234567/); const output = JSON.stringify([result.jsonBody, logs, warnings]); assert.doesNotMatch(output, /FORGED/); @@ -190,6 +210,71 @@ test('SMS/voice preserve content and correlation without reflecting headers or l assert.equal(fetchMock.mock.callCount(), 2); }); +test('Function exchanges a managed identity assertion for Soprano JWT, never from SAS, and keeps it private', async () => { + const context = { ...delivery, providerJwt: 'FORGED-PAYLOAD', + textToVoice: { beforePasswordText: 'Your code is', password: '001234', language: 'en-US' } }; + for (const channel of [1, 2]) { + for (const flag of ['false', 'true']) { + process.env.EPP_PROVIDER_JWT_ENABLED = flag; + const result = await invoke(await envelope({ channel }, context), { authorization: 'Bearer FORGED-INBOUND' }); + assert.equal(result.status, 200); + const sent = fetchMock.mock.calls.at(-1).arguments[1]; + assert.equal(sent.headers.Authorization, flag === 'true' ? `Bearer ${providerToken}` : undefined); + assert.ok(logs[0].startsWith(`[EPP] SopranoAuth=${flag === 'true' ? 'api-key+jwt' : 'api-key'} CorrelationId=`)); + assert.equal(sent.headers['X-MEMS-API-ID'], 'PRIVATE-API-KEY'); + assert.equal(sent.headers['X-MEMS-API-Key'], 'PRIVATE-API-KEY'); + assert.equal(sent.body.includes(providerToken), false); + assert.equal(sent.body.includes('FORGED-PAYLOAD'), false); + assert.equal(JSON.stringify([result, logs, warnings]).includes(providerToken), false); + assert.equal(JSON.stringify([sent, result, logs, warnings]).includes('PRIVATE-EXCHANGE-ASSERTION'), false); + assert.equal(JSON.stringify([sent, result, logs, warnings]).includes('FORGED-INBOUND'), false); + } + } + assert.equal(getToken.mock.callCount(), 2); + assert.equal(getToken.mock.calls[0].arguments[0], process.env.EPP_PROVIDER_SCOPE); + assert.ok(getToken.mock.calls[0].arguments[1].abortSignal instanceof AbortSignal); + assert.ok(getSecret.mock.calls.every(call => ['soprano-api-id', 'soprano-api-key'].includes(call.arguments[0]))); + const result = await invoke(await envelope(), { authorization: 'Bearer FORGED-INBOUND' }); + assert.equal(result.status, 200); + assert.equal(fetchMock.mock.calls.at(-1).arguments[1].headers.Authorization, `Bearer ${providerToken}`); +}); + +test('Entra failure falls back to keys, evaluation skips acquisition, rejection never resends', async () => { + process.env.EPP_PROVIDER_JWT_ENABLED = 'true'; + const evaluated = await invoke(await envelope({ mode: 2 }, { ...delivery, providerJwt: 'invalid' })); + assert.equal(evaluated.status, 200); + assert.equal(getToken.mock.callCount(), 0); + assert.equal(getSecret.mock.callCount(), 0); + assert.equal(fetchMock.mock.callCount(), 0); + getToken.mock.mockImplementation(async () => { throw new Error('PRIVATE-TOKEN-ERROR'); }); + assert.equal((await invoke(await envelope())).status, 200); + assert.equal(fetchMock.mock.calls.at(-1).arguments[1].headers.Authorization, undefined); + assert.doesNotMatch(JSON.stringify([logs, warnings]), /PRIVATE/); + getToken.mock.mockImplementation(async () => ({ token: providerToken, expiresOnTimestamp: Date.now() + 3600000 })); + fetchMock.mock.mockImplementation(async () => ({ ok: false, status: 401, text: async () => '{"status":"REJECTED"}' })); + assertFailure(await invoke(await envelope()), 401); + assert.equal(fetchMock.mock.callCount(), 2); + getSecret.mock.mockImplementation(async () => ({ value: '' })); + process.env.KEY_VAULT_URL = 'https://missing-key.vault.azure.net'; + const tokenCalls = getToken.mock.callCount(); + assertFailure(await invoke(await envelope()), 502); + assert.equal(fetchMock.mock.callCount(), 2); + assert.equal(getToken.mock.callCount(), tokenCalls); +}); + +test('optional Soprano JWT does not affect another provider', async () => { + process.env.EPP_PROVIDER_NAME = 'infobip'; + process.env.EPP_PROVIDER_JWT_ENABLED = 'true'; + fetchMock.mock.mockImplementation(async () => ({ ok: true, status: 200, + text: async () => JSON.stringify({ messages: [{ status: { groupName: 'PENDING' } }] }) })); + const result = await invoke(await envelope({}, { ...delivery, providerJwt: 'do-not-send-this' })); + assert.equal(result.status, 200); + const sent = fetchMock.mock.calls[0].arguments[1]; + assert.equal(sent.headers.Authorization, 'App PRIVATE-API-KEY'); + assert.equal(JSON.stringify(sent).includes('do-not-send-this'), false); + assert.equal(getToken.mock.callCount(), 0); +}); + test('handler awaits the provider body and returns 502/429 without a nonce or retries', async () => { for (const status of [500, 429]) { let release; @@ -203,7 +288,8 @@ test('handler awaits the provider body and returns 502/429 without a nonce or re try { await started; assert.equal(settled, false); - assert.deepEqual(logs, []); + assert.equal(logs.length, 1); + assert.ok(logs[0].startsWith('[EPP] SopranoAuth=api-key CorrelationId=')); } finally { release(JSON.stringify({ status: 'ENROUTE', description: 'PRIVATE-STATUS' })); } diff --git a/python/README.md b/python/README.md index de7afdb..10e52a6 100644 --- a/python/README.md +++ b/python/README.md @@ -51,6 +51,13 @@ strings, including optional `EPP_PROVIDER_TIMEOUT_MS: "1500"`. Replace placehold keys belong in the manifest-named Key Vault secrets, not this file. See the [complete variable table](../README.md#configure-environment-variables). +Optional Soprano JWT acquisition uses `ManagedIdentityCredential` and `ClientAssertionCredential`, not an application secret. +Set `EPP_PROVIDER_SCOPE` to the provider API's Application ID or URI plus `/.default`, and enable +`EPP_PROVIDER_JWT_ENABLED` only after provider authorization. Configure `EPP_PROVIDER_TENANT_ID`, +`EPP_PROVIDER_APPLICATION_ID`, and `EPP_PROVIDER_MI_CLIENT_ID` for the federated exchange. +`AZURE_CLIENT_ID` remains independent for Key Vault. See [JWT setup](../README.md#soprano-jwt-setup) +for tenant requirements. Local tests mock the identity SDK; CLI login is not a token fallback. + Core Tools loads `Values` into `os.environ`. Direct Python execution and pytest do not automatically read local settings. [read_config](src/config.py) returns an `AppConfig` object; the handler/engine use attributes such as `config.provider_name`, not dictionary key lookups. Restart the host after diff --git a/python/src/dispatch.py b/python/src/dispatch.py index e08913c..474047f 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -1,5 +1,7 @@ import base64 +import hashlib import json +import logging import os from urllib.parse import urlsplit @@ -279,6 +281,10 @@ def dispatch(self, dispatch, request_id): if not _valid_provider_url(endpoint): return 502, self._fail_body(provider_id, channel, "invalid provider endpoint", dispatch, request_id) + acquire_token = getattr(adapter, "acquire_token", None) + if callable(acquire_token): + credential["token"] = acquire_token(config.env) + try: provider_request = adapter.build_request(channel, endpoint, dispatch, credential, config.env) except Exception: @@ -286,6 +292,11 @@ def dispatch(self, dispatch, request_id): if not _valid_provider_url(provider_request.get("url")): return 502, self._fail_body(provider_id, channel, "invalid provider request URL", dispatch, request_id) + if provider_id == "soprano": + correlation_hash = hashlib.sha256(str(dispatch.correlation_id or "").encode()).hexdigest()[:16] + auth_mode = "api-key+jwt" if provider_request["headers"].get("Authorization") else "api-key" + logging.info("[EPP] SopranoAuth=%s CorrelationId=%s", auth_mode, correlation_hash) + timeout_ms = _provider_timeout_ms(config.provider_timeout_ms) response = None try: diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index 4a651bc..1627ac0 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -1,7 +1,28 @@ import json +import logging +import time +from contextvars import ContextVar +from threading import Lock + +from azure.identity import ManagedIdentityCredential, ClientAssertionCredential from ..models import ParsedResponse, TextToVoice +_token_request = ContextVar("soprano_token_request", default=False) + + +class _TokenLogFilter(logging.Filter): + def filter(self, record): + return not (_token_request.get() and record.name.startswith(("azure.identity", "azure.core", "msal"))) + + +_token_log_filter = _TokenLogFilter() + + +def _jwt_enabled(env): + flag = env.get("EPP_PROVIDER_JWT_ENABLED") + return isinstance(flag, str) and flag.strip().lower() == "true" + class SopranoProvider: manifest = { @@ -19,6 +40,49 @@ class SopranoProvider: }, } + def __init__(self): + self._credential = None + self._credential_settings = None + self._credential_lock = Lock() + + def acquire_token(self, env): + if not _jwt_enabled(env): + return "" + scope = env.get("EPP_PROVIDER_SCOPE") + settings = tuple(env.get(name) for name in ("EPP_PROVIDER_TENANT_ID", "EPP_PROVIDER_APPLICATION_ID", "EPP_PROVIDER_MI_CLIENT_ID")) + if not all(isinstance(value, str) and value.strip() for value in (scope, *settings)): + return "" + settings = tuple(value.strip() for value in settings) + loggers = (logging.getLogger(), *logging.Logger.manager.loggerDict.copy().values()) + for logger in loggers: + if isinstance(logger, logging.Logger): + for handler in logger.handlers: + if _token_log_filter not in handler.filters: + handler.addFilter(_token_log_filter) + context_token = _token_request.set(True) + try: + with self._credential_lock: + if self._credential is None or self._credential_settings != settings: + tenant, application_id, identity = settings + managed_identity = ManagedIdentityCredential(client_id=identity, retry_total=0, + connection_timeout=2.5, read_timeout=2.5, logging_enable=False) + def get_assertion(): + assertion = managed_identity.get_token("api://AzureADTokenExchange/.default", logging_enable=False) + if assertion.expires_on <= time.time() + 30 or not isinstance(assertion.token, str) or not assertion.token.strip(): + raise ValueError("managed identity assertion unavailable") + return assertion.token + self._credential = ClientAssertionCredential(tenant, application_id, get_assertion, + authority="https://login.microsoftonline.com", retry_total=0, + connection_timeout=2.5, read_timeout=2.5, logging_enable=False) + self._credential_settings = settings + credential = self._credential + result = credential.get_token(scope.strip(), logging_enable=False) + return result.token if result.expires_on > time.time() + 30 and isinstance(result.token, str) and result.token.strip() else "" + except Exception: + return "" + finally: + _token_request.reset(context_token) + def build_request(self, channel, endpoint, dispatch, credential, env): message_type = "voice" if channel == "voice" else "sms" headers = { @@ -27,6 +91,9 @@ def build_request(self, channel, endpoint, dispatch, credential, env): "Content-Type": "application/json", "Accept": "application/json", } + token = credential.get("token") + if _jwt_enabled(env) and isinstance(token, str) and token.strip(): + headers["Authorization"] = "Bearer " + token body = { "destination": str(dispatch.destination).lstrip("+"), "messageTypes": [message_type], diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index f5afb09..36d9f4b 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -1,7 +1,10 @@ import json +import logging +from concurrent.futures import ThreadPoolExecutor from unittest.mock import Mock import pytest +from azure.core.credentials import AccessToken from urllib3.exceptions import ReadTimeoutError import src.dispatch as dispatch_module @@ -10,6 +13,7 @@ from src.models import DeliveryContext, Envelope, TextToVoice from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider +import src.providers.soprano as soprano_module def _request(channel="sms"): @@ -65,6 +69,89 @@ def test_incomplete_soprano_voice_never_sends(engine, speech): dispatch_module.requests.request.assert_not_called() +def test_soprano_reuses_federated_credentials_and_requests_provider_scope(monkeypatch): + adapter = SopranoProvider() + token = "opaque-access-token-from-entra" + env = {"EPP_PROVIDER_JWT_ENABLED": " TRUE ", "EPP_PROVIDER_SCOPE": "api://provider-application-id/.default", + "EPP_PROVIDER_TENANT_ID": "11111111-1111-4111-8111-111111111111", + "EPP_PROVIDER_APPLICATION_ID": "22222222-2222-4222-8222-222222222222", + "EPP_PROVIDER_MI_CLIENT_ID": "33333333-3333-4333-8333-333333333333"} + managed_identity = Mock(get_token=Mock(return_value=AccessToken("private-exchange-assertion", 3700))) + identity_factory = Mock(return_value=managed_identity) + def exchange(*args, **kwargs): + assert factory.call_args.args[2]() == "private-exchange-assertion" + return AccessToken(token, 3700) + credential = Mock(get_token=Mock(side_effect=exchange)) + factory = Mock(return_value=credential) + monkeypatch.setattr(soprano_module, "ManagedIdentityCredential", identity_factory) + monkeypatch.setattr(soprano_module, "ClientAssertionCredential", factory) + monkeypatch.setattr(soprano_module.time, "time", lambda: 100) + for flag in (None, "false", "1", "yes", True): + assert adapter.acquire_token({**env, "EPP_PROVIDER_JWT_ENABLED": flag}) == "" + for name in ("EPP_PROVIDER_SCOPE", "EPP_PROVIDER_TENANT_ID", "EPP_PROVIDER_APPLICATION_ID", "EPP_PROVIDER_MI_CLIENT_ID"): + for value in (None, "", " "): + assert adapter.acquire_token({**env, name: value}) == "" + factory.assert_not_called() + identity_factory.assert_not_called() + assert adapter.acquire_token(env) == token + identity_factory.assert_called_once_with(client_id=env["EPP_PROVIDER_MI_CLIENT_ID"], retry_total=0, connection_timeout=2.5, read_timeout=2.5, logging_enable=False) + assert factory.call_args.args[:2] == (env["EPP_PROVIDER_TENANT_ID"], env["EPP_PROVIDER_APPLICATION_ID"]) + managed_identity.get_token.assert_called_once_with("api://AzureADTokenExchange/.default", logging_enable=False) + credential.get_token.assert_called_once_with(env["EPP_PROVIDER_SCOPE"], logging_enable=False) + assert adapter.acquire_token(env) == token + assert factory.call_count == 1 + assert identity_factory.call_count == 1 + assert adapter.acquire_token({**env, "EPP_PROVIDER_MI_CLIENT_ID": "44444444-4444-4444-8444-444444444444"}) == token + assert factory.call_count == 2 and identity_factory.call_args.kwargs["client_id"] == "44444444-4444-4444-8444-444444444444" + assert adapter.acquire_token({**env, "EPP_PROVIDER_SCOPE": "api://another-provider/.default"}) == token + assert credential.get_token.call_args.args == ("api://another-provider/.default",) + for assertion in (None, AccessToken("", 3700), AccessToken("assertion", 100)): + managed_identity.get_token.return_value = assertion + assert adapter.acquire_token(env) == "" + managed_identity.get_token.side_effect = RuntimeError("private assertion failure") + assert adapter.acquire_token(env) == "" + credential.get_token.side_effect = None + for result in (None, AccessToken(token, 100), AccessToken("", 3700), AccessToken(" ", 3700), AccessToken(False, 3700)): + credential.get_token.return_value = result + assert adapter.acquire_token(env) == "" + + +@pytest.mark.parametrize("failure_stage", ["assertion", "exchange"]) +def test_soprano_token_failure_diagnostics_are_request_scoped(monkeypatch, caplog, failure_stage): + caplog.set_level(logging.DEBUG) + env = {"EPP_PROVIDER_JWT_ENABLED": "true", "EPP_PROVIDER_SCOPE": "api://provider/.default", + "EPP_PROVIDER_TENANT_ID": "11111111-1111-4111-8111-111111111111", + "EPP_PROVIDER_APPLICATION_ID": "22222222-2222-4222-8222-222222222222", + "EPP_PROVIDER_MI_CLIENT_ID": "33333333-3333-4333-8333-333333333333"} + sdk_logs = [logging.getLogger(name) for name in ("azure.identity._internal.decorators", + "azure.identity._internal.get_token_mixin", "msal.managed_identity", "azure.core.pipeline")] + def fail(*args, **kwargs): + for logger in sdk_logs: + logger.warning("PRIVATE SDK account/exception data") + with ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(sdk_logs[0].warning, "other-request-diagnostic").result() + logging.info("application-log-remains-visible") + raise RuntimeError("PRIVATE-TOKEN-ERROR") + monkeypatch.setattr(soprano_module, "ManagedIdentityCredential", Mock(return_value=Mock(get_token=Mock(side_effect=fail)))) + factory = Mock() + factory.return_value.get_token.side_effect = fail if failure_stage == "exchange" else lambda *args, **kwargs: factory.call_args.args[2]() + monkeypatch.setattr(soprano_module, "ClientAssertionCredential", factory) + sdk_output = [] + handler = logging.Handler() + handler.emit = lambda record: sdk_output.append(record.getMessage()) + sdk_logs[0].addHandler(handler) + try: + assert SopranoProvider().acquire_token(env) == "" + sdk_logs[0].warning("after-token-request") + finally: + sdk_logs[0].removeHandler(handler) + handler.close() + assert sdk_output == ["other-request-diagnostic", "after-token-request"] + assert "PRIVATE" not in caplog.text + assert "other-request-diagnostic" in caplog.text and "application-log-remains-visible" in caplog.text + assert "after-token-request" in caplog.text + + def test_base_and_sinch_voice_final_url_guards(engine): for url in ("http://api.example", "https://api.example:0"): engine.env["EPP_PROVIDER_ENDPOINT"] = url diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index fdae365..21358d6 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -8,11 +8,13 @@ from unittest.mock import Mock import azure.functions as func +from azure.core.credentials import AccessToken import pytest from jwcrypto import jwe, jwk import function_app import src.dispatch as dispatch_module +import src.providers.soprano as soprano_module _KEY = jwk.JWK.generate(kty="RSA", size=2048) _PRIVATE_PEM = _KEY.export_to_pem(private_key=True, password=None).decode() @@ -38,6 +40,8 @@ def _isolate(monkeypatch): ) monkeypatch.setattr(function_app, "_engine", engine) monkeypatch.setattr(dispatch_module.requests, "request", Mock()) + monkeypatch.setattr(function_app._registry.get("soprano"), "_credential", None) + monkeypatch.setattr(soprano_module, "ManagedIdentityCredential", Mock(side_effect=AssertionError("Unexpected token request"))) def _request(body, headers=None): @@ -181,13 +185,90 @@ def wait_for_acceptance(*args, **kwargs): assert wire["voice"] == {"text2voice": speech} assert "text" not in wire and wire["messageTypes"] == ["voice"] and wire["correlationId"] == _CORRELATION summary = json.loads(caplog.records[-1].getMessage().removeprefix("[EPP] result ")) - assert len(caplog.records) == 1 + assert len(caplog.records) == 2 + assert caplog.records[0].getMessage() == "[EPP] SopranoAuth=api-key CorrelationId=" + summary["correlationId"] assert set(summary) == {"requestId", "correlationId", "httpStatus", "elapsedMs", "evaluation"} assert summary["correlationId"] == hashlib.sha256(_CORRELATION.encode()).hexdigest()[:16] for private in (_NONCE, _PHONE, _MESSAGE, "123456", "001234", _CORRELATION, "wire-message", "test-key"): assert private not in caplog.text +@pytest.mark.parametrize("channel", [1, 2]) +def test_soprano_jwt_is_acquired_by_the_function_not_the_sas_payload(monkeypatch, caplog, channel): + caplog.set_level(logging.INFO) + token = "eyJhbGciOiJSUzI1NiJ9.eyJ2ZXIiOiIyLjAifQ.c2lnbmF0dXJl" + context = {**_CONTEXT, "providerJwt": "FORGED-PAYLOAD", + "textToVoice": {"beforePasswordText": "Code", "password": "001234", "language": "en-US"}} + send = dispatch_module.requests.request + send.return_value = Mock(status_code=201, json=Mock(return_value={"status": "ENROUTE"})) + function_app._engine.env["EPP_PROVIDER_SCOPE"] = "api://provider-application-id/.default" + function_app._engine.env.update(EPP_PROVIDER_TENANT_ID="11111111-1111-4111-8111-111111111111", + EPP_PROVIDER_APPLICATION_ID="22222222-2222-4222-8222-222222222222", + EPP_PROVIDER_MI_CLIENT_ID="33333333-3333-4333-8333-333333333333") + def exchange(*args, **kwargs): + assert factory.call_args.args[2]() == "PRIVATE-EXCHANGE-ASSERTION" + return AccessToken(token, soprano_module.time.time() + 3600) + get_token = Mock(side_effect=exchange) + factory = Mock(return_value=Mock(get_token=get_token)) + monkeypatch.setattr(soprano_module, "ClientAssertionCredential", factory) + monkeypatch.setattr(soprano_module, "ManagedIdentityCredential", Mock(return_value=Mock(get_token=Mock( + return_value=AccessToken("PRIVATE-EXCHANGE-ASSERTION", soprano_module.time.time() + 3600))))) + for flag in ("false", "true"): + function_app._engine.env["EPP_PROVIDER_JWT_ENABLED"] = flag + response = _HANDLER(_request(_envelope(channel=channel, encryptedDeliveryContext=_encrypt(context=context)), + {"Authorization": "Bearer FORGED-INBOUND"})) + assert response.status_code == 200 + sent = send.call_args.kwargs + assert sent["headers"].get("Authorization") == ("Bearer " + token if flag == "true" else None) + auth_mode = "api-key+jwt" if flag == "true" else "api-key" + assert f"[EPP] SopranoAuth={auth_mode} CorrelationId=" in caplog.records[-2].getMessage() + assert sent["headers"]["X-MEMS-API-ID"] == sent["headers"]["X-MEMS-API-Key"] == "test-key" + assert token not in sent["data"] + response.get_body().decode() + caplog.text + assert "PRIVATE-EXCHANGE-ASSERTION" not in str(sent) + response.get_body().decode() + caplog.text + assert "FORGED" not in str(sent) + if flag == "false": + get_token.assert_not_called() + context.pop("providerJwt") + response = _HANDLER(_request(_envelope(channel=channel, providerJwt=token, encryptedDeliveryContext=_encrypt(context=context)), + {"Authorization": "Bearer FORGED-INBOUND", "x-provider-jwt": token})) + assert response.status_code == 200 and send.call_args.kwargs["headers"]["Authorization"] == "Bearer " + token + factory.assert_called_once() + assert get_token.call_count == 2 + get_token.assert_called_with(function_app._engine.env["EPP_PROVIDER_SCOPE"], logging_enable=False) + assert all(call.args[0] in ("soprano-api-id", "soprano-api-key") for call in function_app._engine.secrets.resolve.call_args_list) + send.reset_mock() + send.return_value = Mock(status_code=401, json=Mock(return_value={"status": "REJECTED"})) + response = _HANDLER(_request(_envelope(channel=channel, encryptedDeliveryContext=_encrypt(context=context)))) + assert response.status_code == 401 and "nonce" not in json.loads(response.get_body()) + send.assert_called_once() + for missing in ("soprano-api-id", "soprano-api-key"): + function_app._engine.secrets.resolve.side_effect = lambda name: "" if name == missing else "test-key" + get_token.reset_mock() + response = _HANDLER(_request(_envelope(channel=channel, encryptedDeliveryContext=_encrypt(context=context)))) + assert response.status_code == 502 + get_token.assert_not_called() + send.assert_called_once() + function_app._engine.secrets.resolve.side_effect = None + send.return_value = Mock(status_code=201, json=Mock(return_value={"status": "ENROUTE"})) + get_token.side_effect = RuntimeError("PRIVATE-TOKEN-ERROR") + response = _HANDLER(_request(_envelope(channel=channel, encryptedDeliveryContext=_encrypt(context=context)))) + assert response.status_code == 200 and "Authorization" not in send.call_args.kwargs["headers"] + assert "PRIVATE-TOKEN-ERROR" not in caplog.text + + +def test_soprano_evaluation_skips_token_acquisition_and_live_ignores_payload_tokens(monkeypatch): + function_app._engine.env["EPP_PROVIDER_JWT_ENABLED"] = "true" + for provider_jwt in (False, {}, "bad\r\nheader"): + compact = _encrypt(context={**_CONTEXT, "providerJwt": provider_jwt}) + response = _HANDLER(_request(_envelope(mode=2, encryptedDeliveryContext=compact))) + assert response.status_code == 200 + function_app._engine.secrets.resolve.assert_not_called() + response = _HANDLER(_request(_envelope(encryptedDeliveryContext=compact))) + assert response.status_code == 502 and "nonce" not in json.loads(response.get_body()) + assert "Authorization" not in dispatch_module.requests.request.call_args.kwargs["headers"] + soprano_module.ManagedIdentityCredential.assert_not_called() + + def test_provider_failure_preserves_status_without_retry_or_nonce(monkeypatch): upstream = Mock(status_code=429, json=Mock(return_value={"status": "ENROUTE"})) send = Mock(return_value=upstream)