diff --git a/README.md b/README.md index 3a5cfbc..f9cebbe 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,16 @@ Choose one language and configure the adapter for your provider. No provider is by default. Deploy each language separately, not all three to the same Function App. See the [shared configuration](docs/CONTRACT.md#default-provider-and-configuration-readers). +**Using one provider?** Only that provider needs settings and credentials. Keeping the other adapter +files is harmless. To omit them from a deployment, follow the [single-provider setup](docs/ONBOARDING.md#single-provider-deployments). + New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — setup, config, running, securing, and deploying, step by step. ## The design in one line SAS → Easy Auth → anonymous HTTP handler (`POST /api/SendOtp`, validate envelope + decrypt JWE) → -configured provider (API key) → HTTP result with nonce on success. +configured provider (API key by default; opt-in OAuth for a supporting adapter) → HTTP result with nonce on success. 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. @@ -47,8 +50,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**; local HTTP-only execution can omit that storage setting. Do not copy +`UseDevelopmentStorage=true` into Azure. The sample shows safe auth-gate defaults; other optional +settings stay in the table below rather than appearing as required placeholders. 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 @@ -64,6 +68,8 @@ how code accesses configuration, not the environment-variable names. | `EPP_ENCRYPTION_KEY_ID` | Optional | Expected encryption key ID; mismatch only produces an advisory warning. | | `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_AUTH_MODE` | Optional | Defaults to `apiKey`; `oauth2` requires a provider JWT. See the [auth gate table](docs/CONTRACT.md#provider-authentication-gates). | +| `EPP_PROVIDER_JWT_ENABLED` | Optional | Defaults to `false`: no provider-token lookup. `true` enables optional JWT alongside API keys or required JWT in `oauth2` mode, only for supporting adapters. | | `EPP_PROVIDER_TIMEOUT_MS` | Optional | Decimal milliseconds. Defaults to `1500`, capped at `2500`; not an end-to-end deadline. | | `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. | @@ -90,8 +96,9 @@ 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. Outbound OAuth settings are +separate from that inbound trust; see the [configuration catalog](docs/CONTRACT.md#4-configuration-app-settings--env) +and [onboarding](docs/ONBOARDING.md#provider-jwt-setup) for token setup and structured voice input. ## Security @@ -105,8 +112,9 @@ internet with Easy Auth disabled or bypassed.** See [platform setup](docs/ONBOAR JWE decryption protects the payload but **does not authenticate SAS**: anyone with the public key can encrypt a request. A nonce echo, including a fixed nonce, is not caller authentication. Provider API -keys are read from **Key Vault** via **managed identity**; they authenticate the outbound provider call, -not the inbound request. +keys or OAuth client secrets are read from **Key Vault** via **managed identity**. A supported OAuth +adapter may instead exchange a managed-identity assertion for a provider token. These credentials +authenticate the outbound provider call, not the inbound request; the caller's token is never forwarded. Core Tools does not provide Easy Auth. Local execution is unauthenticated: bind only to loopback, with no tunnels or public forwarding. Offline tests cover application behavior, not platform diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 01ab72d..8a08be5 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -5,7 +5,8 @@ This defines the shared contract for [JavaScript](../javascript/), [Python](../p > **Naming.** EPP means **External Phone Provider**. App settings use the `EPP_` prefix; the > request and delivery models are `Envelope`, `DeliveryContext` and `DispatchRequest`. -> Documentation names do not change the external JSON fields or `microsoft.mfa.otpDeliver.v1` version. +> Existing JSON fields and `microsoft.mfa.otpDeliver.v1` stay unchanged. This branch adds an opt-in +> encrypted `voice.text2voice` extension requiring upstream caller support; see below. The design is **one dispatch engine + registered provider adapters**, with one selected provider per deployment. API-specific paths, headers, payloads and status rules belong in adapters, not this guide. @@ -76,13 +77,20 @@ runs once per language. Tag tampering and original-header-byte tests remain. |-------|----------|-------| | `nonce` | yes | value the endpoint MUST echo to prove decryption | | `phoneNumber` | yes | caller supplies an E.164 string; full E.164 validation is an implementation gap | -| `message` | yes | fully rendered, localized text containing the passcode; forward unchanged, including caller-supplied voice digit spacing. Do not extract, infer or guess a passcode | +| `message` | yes | fully rendered, localized text containing the passcode; forward unchanged where used. Structured-voice adapters use the explicit voice fields instead. Do not extract, infer or guess a passcode | | `extension` | no | office-voice contract field; not currently forwarded by the shared dispatch model | | `locale` | no | voice selection input where supported by the selected adapter | | `riskContext` | no | contextual request data; no risk-policy evaluation is implemented here | +| `voice.text2voice` | adapter-dependent | opt-in encrypted object with string `beforePasswordText`, nonblank string `password`, and nonblank string `language`; required for live voice by adapters declaring `requiresTextToVoice` | Decryption failure → `400`. Missing `nonce` / `phoneNumber` / `message` → `400`. +`voice.text2voice` comes only from the JWE and maps to a redacted `TextToVoice` model. Strings and +leading zeroes are preserved; an empty prefix is allowed. Missing fields for a supporting live-voice +adapter return `400 provider_delivery_failed` without a nonce, before credential/provider I/O. +SMS, other adapters and evaluation ignore it. Confirm upstream caller support for this opt-in +extension; see the [voice example](ONBOARDING.md#structured-voice-input). + 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. @@ -116,7 +124,7 @@ return `error: "decryption_failed"` without a cryptographic reason or nonce. The only shared non-delivery control is the existing incoming `mode: 2` or `mode: "evaluation"`, for every provider and language. On Azure, Easy Auth still authenticates the caller before the handler validates the envelope and decrypts the JWE with integrity checks. Provider lookup, provider -Key Vault reads and outbound provider HTTP are skipped. No provider name, endpoint or credentials +Key Vault reads, provider-token acquisition and outbound provider HTTP are skipped. No provider name, endpoint or credentials 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. @@ -132,6 +140,11 @@ an HTTP status. **Fail-closed:** an unknown/unmapped status is treated as `Fail` An unsuccessful provider HTTP response cannot become `Continue` because its body contains a success-looking status. Explicit `Block`/`StepUp` outcomes remain non-success responses. +Malformed JSON in a successful HTTP response returns `502`, not acceptance. Bundled status-based +adapters require a recognized, correctly typed status; ID-based adapters require a nonblank message +or call identifier. An empty body/object or an HTML success page is not submission evidence. Failed +HTTP responses retain their existing status mapping even when their bodies cannot be parsed. + | Outcome | HTTP | When | |---------|------|------| | `Continue` | `200` | recognized success status | @@ -152,13 +165,20 @@ Each provider is one unit exposing three things: - **`manifest`** — protocol facts only: - `id` — provider id selected by `EPP_PROVIDER_NAME`; its base URL is `EPP_PROVIDER_ENDPOINT` - - `auth` — `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }`; other modes fail closed + - `auth` — `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }` + - OAuth capability — `supportsOAuth` on the JavaScript manifest, `auth.supports_oauth` in Python, + `Auth.SupportsOAuth` in .NET; the JWT gate requires explicit adapter support + - `requiresTextToVoice` — optional capability for explicit structured live-voice input (snake_case in Python) - `responseMapping` — map of provider status → `Continue` | `Fail` | `Block` | `StepUp` (+ `default`) - **`buildRequest({ channel, endpoint, dispatch, credential, env })`** → `{ url, method, headers, body }` - **`parseResponse({ httpStatus, ok, json })`** → `ParsedResponse`, containing `success`, `providerHttpStatus`, optional `providerMessageId`, `providerStatusName`, `providerStatusCode` and `providerStatusDescription` (snake_case attributes in Python, PascalCase in .NET). +Resolved credentials contain a selected `mode`, API-key `secret`/`identity` when required, and an +optional `token`. API-key mode retains its headers even when a JWT is attached; OAuth mode uses only +the required token. Token acquisition stays outside request builders. + The adapter reads its API-specific JSON and constructs a normalized `ParsedResponse` object: [JavaScript](../javascript/src/functions/models.js), [Python](../python/src/models.py), [.NET](../dotnet/Src/Models.cs). The engine reads named properties/attributes rather than provider JSON @@ -175,7 +195,7 @@ class hierarchy is required. Adapters require registration in the chosen runtime. Consult the selected adapter and its manifest for required credentials and options: the manifest declares secret names and protocol mappings; the implementation reads adapter-specific options from app settings. Do not duplicate individual -API contracts or credential catalogs in shared onboarding documentation. +API contracts or credential catalogs; onboarding examples cover only the integration-specific additions. --- @@ -187,6 +207,8 @@ Set by provisioning. **Identical names across all languages.** |-----|---------| | `EPP_PROVIDER_NAME` | registered id of the selected provider; `` is a placeholder, not a bundled default | | `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_AUTH_MODE` | `apiKey` by default, or `oauth2` for required provider JWT authentication; trimmed and case-insensitive | +| `EPP_PROVIDER_JWT_ENABLED` | `false` by default; trimmed, case-insensitive `true`/`false` only. Enables provider-token acquisition for supporting adapters | | `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_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 | @@ -194,10 +216,31 @@ Set by provisioning. **Identical names across all languages.** | `KEY_VAULT_URL` | Key Vault URI (provider API keys) | | `AZURE_CLIENT_ID` | set for a user-assigned managed identity | -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 -values in code or app settings. No additional customer-private configuration or new environment -variable is needed for this guidance. +Provider API 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 values in code or app settings. + +### Provider authentication gates + +| `EPP_PROVIDER_AUTH_MODE` | `EPP_PROVIDER_JWT_ENABLED` | Live behavior | +|---|---|---| +| `apiKey` (default) | `false` (default) | API credentials only; no token lookup or OAuth configuration validation | +| `apiKey` | `true` | Validate API credentials first; attach a JWT when available. Token configuration/acquisition failures leave API-key-only delivery usable | +| `oauth2` | `true` | JWT required; no API-key lookup or headers. Missing/invalid token fails before delivery | +| `oauth2` | `false` | Configuration error (`502`), with no credential lookup or delivery | + +Invalid gates/modes or an unsupported adapter fail before credential I/O; evaluation skips these +checks. A JWT never replaces missing API keys. **One provider send only:** no retry after rejection +or automatic provider failover. Combined-header authentication precedence is controlled by the provider, +not the local auth-mode setting; keep optional JWT disabled until that behavior is confirmed. + +Azure Identity reuses one credential/cache, replaced on configuration or resolved-secret changes. +Tokens stay in memory, never logs/responses or inbound-token forwarding. See [setup](ONBOARDING.md#provider-jwt-setup). + +Authentication and sending use separate `EPP_PROVIDER_TIMEOUT_MS` limits. JavaScript/.NET bound the +authentication wait; Python bounds lock wait and connect/read inactivity, not total time. Cold SDK +and secret operations can add latency even for optional JWT. Required-token timeouts return `504` +when recognized; other failures return `502`, without sending. Optional-token failures retain API keys. Caller trust is configured in **Easy Auth**, not application environment variables: pin the trusted tenant issuer, the endpoint-app audience and the authorized SAS caller application ID. Incoming @@ -210,6 +253,10 @@ Provision `EPP_PROVIDER_NAME` with the customer's selected provider, plus that a `EPP_PROVIDER_ENDPOINT` and Key Vault credentials. A missing or unknown provider fails closed; there is no implicit default or automatic failover. Request-body provider fields are not used. +JavaScript/Python defer adapter imports until live selection; a missing/broken selected module returns +`502` before credential or provider HTTP work. Unused modules are never imported. .NET uses explicit +compile-time registrations. See [single-provider setup](ONBOARDING.md#single-provider-deployments). + The shared configuration readers are [JavaScript `readConfig`](../javascript/src/functions/config.js), [Python `read_config`](../python/src/config.py), and [.NET `AppConfig.Read`](../dotnet/Src/AppConfig.cs). They return named configuration objects for encryption and the selected provider, not caller-authentication @@ -232,7 +279,8 @@ subscription activation and changing tenant policy belong to provisioning, not t - **Fail-closed** — only `Continue` → `200 accepted`; unknown status → `Fail`. - **Managed identity** — Key Vault access via managed identity only (user-assigned if `AZURE_CLIENT_ID` - set, else system-assigned). No static credentials. + set, else system-assigned). Outbound OAuth optionally uses a Key Vault-backed client secret or + federated user-assigned identity; no plaintext credential app settings or caller-token forwarding. - **Privacy** — never log phone numbers, passcodes, nonce values, bearer tokens, API keys, JWE headers/payloads, raw exceptions or provider responses. There is no plaintext diagnostic override. Each handler writes one summary with a generated request ID, the first 16 lowercase hex characters of the @@ -262,7 +310,8 @@ subscription activation and changing tenant policy belong to provisioning, not t Each language keeps lightweight offline tests covering representative application checks for: -- Bundled adapter request formats and static provider credentials. +- Bundled adapter request formats, API-key headers and opt-in provider-token credential flows. +- Explicit encrypted text-to-voice fields and missing-field rejection before provider I/O. - Fail-closed outcomes, missing credentials, HTTPS guards and timeouts. - Envelope validation and real JWE decryption/tamper rejection. - Evaluation without provider I/O. @@ -287,8 +336,11 @@ 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. -- 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. +- Caller-provided message/voice strings are preserved. Structured-voice delivery requires upstream + caller support for the opt-in encrypted fields. Actual playback, language support and digit-by-digit + pronunciation must be verified with the provider; an accepted request does not prove correct speech. +- QA4 accepted client-secret-issued provider JWTs for SMS and voice. Federation, combined-header + authentication precedence, production provisioning and handset playback still need separate validation. - Full body-size/content-type and E.164 validation, subscription provisioning, certification, least-cost routing and voice-callback workflows are outside this sample. Native fallback belongs to the caller, not this Function. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index bbb14e1..12f740d 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -17,10 +17,27 @@ 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. +### Single-provider deployments + +Configure only `EPP_PROVIDER_NAME`, its matching `EPP_PROVIDER_ENDPOINT`, and the selected adapter's +credentials/options. Unused adapters do not require secrets, subscriptions or endpoints. Keeping the +bundled files is the simplest option; there is no automatic failover to them. + +- **JavaScript/Python:** only the selected adapter is imported for live delivery. You may omit unused + adapter files without changing the loader's fixed provider-ID mapping. Keep the shared modules and + declared dependencies (and Python's provider package initializer). Missing/broken selected modules + return a sanitized `502`; unknown provider IDs return `400`. Evaluation loads no provider modules. +- **.NET:** keep the selected adapter source and remove registrations for omitted types in + [Program.cs](../dotnet/Program.cs). Deleting source without removing its registration fails compilation; + a missing registration fails closed at dispatch. No reflection or dynamic assembly loading is used. +- Bundled tests cover all adapters: run them in the complete repository, or remove the corresponding + adapter-specific tests/imports when maintaining a permanently trimmed source fork. Merely selecting + one provider does not require deleting anything. + ### Setup script compatibility The Preview 1 setup script creates the encryption-key secret, not the selected provider's API -credentials. Before live delivery, complete these steps: +credentials. Before live delivery, complete these steps (or the supported adapter's opt-in OAuth setup): 1. Set `KEY_VAULT_URL` to the vault containing the provider credentials. When it is the vault created by setup, use that vault's `vaultUri`; otherwise explicitly select the credential vault and grant @@ -38,6 +55,9 @@ credentials. Before live delivery, complete these steps: The script already writes the correct `EPP_` names; no variable-prefix translation is required. +Provider tokens require the separate [JWT setup](#provider-jwt-setup) below. API-key-only behavior +remains the default; see the [two-setting auth gate table](CONTRACT.md#provider-authentication-gates). + | Setup value | Current application behavior | |---|---| | `EPP_PROVIDER_NAME` | Selects one registered adapter; no implicit default. | @@ -71,11 +91,64 @@ A public-only certificate cannot supply the private key it later exports. Treat role assignments as failures unless the exact assignment is verified as already present. Verify these script prerequisites separately; the application tests do not validate provisioning. +### Provider JWT setup + +Only Soprano currently supports the JWT gate. QA4 accepted client-secret-issued v2 Bearer tokens for +SMS and voice in local integration tests. Managed-identity federation, combined-header precedence and +handset playback remain unverified. See the [auth gate table](CONTRACT.md#provider-authentication-gates). + +Soprano requires `ver=2.0`, its resource app ID as `aud`, the issuing tenant's +`https://login.microsoftonline.com/{tenantId}/v2.0` issuer, and the registered client app ID as `azp`. +The resource registration controls access-token version; using a v2 token endpoint alone does not +guarantee a v2 token. Confirm the client's provider-side account mapping before rollout. + +| Setting | Value | +|---|---| +| `EPP_PROVIDER_TENANT_ID` | Specific issuing Entra tenant, not `common`, `organizations`, `consumers` or `adfs` | +| `EPP_PROVIDER_CLIENT_ID` | Client application ID requesting the token, not the inbound caller or provider API ID | +| `EPP_PROVIDER_SCOPE` | One agreed provider resource scope ending in `/.default` | +| `EPP_PROVIDER_CLIENT_SECRET_NAME` | **Secret flow:** client-secret name in `KEY_VAULT_URL` | +| `EPP_PROVIDER_MI_CLIENT_ID` | **Federated flow:** user-assigned managed identity client ID | + +Choose exactly one source, with no surrounding whitespace. Plaintext `EPP_PROVIDER_CLIENT_SECRET` +and `EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE` overrides are rejected. + +Azure Identity uses the named client secret or exchanges a managed-identity assertion for +`api://AzureADTokenExchange/.default` through `ClientAssertionCredential`. Federation requires the +matching identity issuer/subject and exchange audience on the client app, plus provider permissions +and consent. `AZURE_CLIENT_ID` selects the separate Key Vault identity. This targets public Azure; +Entra issues the provider token, and the inbound token is never forwarded. + +### Structured voice input + +Soprano's **new omnimsg** adapter appends `/messages/omnimsg` to `EPP_PROVIDER_ENDPOINT`. + +For voice, supply this object **inside the encrypted delivery context**, alongside the existing +required `nonce`, `phoneNumber` and `message` fields: + +```json +{ + "voice": { + "text2voice": { + "beforePasswordText": "Your code is", + "password": "012345", + "language": "en-US" + } + } +} +``` + +Confirm SAS can supply this [opt-in encrypted extension](CONTRACT.md#encrypteddeliverycontext-jwe). +Voice emits `messageTypes: ["voice"]` and the nested object without top-level `text`; SMS keeps `text`. +Language controls pronunciation, not translation. The old Connect Voice PDF's form encoding and +numeric language/voice/loop options do not apply. Acceptance is not proof of audible playback. + ## 2. Provision encryption and deployment trust Use [local.settings.sample.json](local.settings.sample.json) as a starting point, replacing its placeholders with the selected adapter's configuration and choosing the matching worker runtime. -The sample's `UseDevelopmentStorage=true` is local-only and requires Azurite. Keep local settings +The sample's `UseDevelopmentStorage=true` is local-only and requires Azurite when used; HTTP-only +local execution can omit it. Keep local settings private; set application values in the Function App environment for deployment, configure its host storage separately, and use a Key Vault reference instead of a local private-key value. The [configuration catalog](CONTRACT.md#4-configuration-app-settings--env) is authoritative. @@ -133,7 +206,7 @@ platform gate before publishing this code, then repeat the deployed security che Use `POST /api/SendOtp` with an admitted caller's token and a valid encrypted envelope containing `mode: 2` or `mode: "evaluation"`. On Azure, Easy Auth authenticates and authorizes the caller first. The handler then validates and decrypts, and echoes the nonce without provider lookup, provider Key -Vault reads or provider HTTP. No provider configuration or diagnostic environment flag is required. +Vault reads, provider-token acquisition or provider HTTP. No provider configuration or diagnostic environment flag is required. Platform trust configuration and the decryption key remain prerequisites; see the [evaluation contract](CONTRACT.md#evaluation-generic-shutter). @@ -145,7 +218,10 @@ does not enable authentication, and local success does not verify platform secur Evaluation success proves the validation/decryption path, not live credentials or handset delivery. A live `200` with the matching nonce means provider acceptance, not handset receipt; confirm delivery through the selected provider's reports. Forward the rendered message unchanged, including voice -digit spacing, without guessing a passcode. A timed-out send may already be accepted; avoid blind retries. +digit spacing, without guessing a passcode. Adapters requiring structured voice input must receive it +explicitly inside the JWE; do not derive a password from the rendered message. Evaluation does not +verify provider-specific voice fields or actual playback. A timed-out send may already be accepted; +avoid blind retries. ## 4. Package, deploy and verify diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index 6071ed5..f04bc7b 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -1,5 +1,5 @@ { - "_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.", + "_comment": "Local API-key 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, or omit storage for HTTP-only local use. Provider credentials stay in Key Vault. Local hosts have no Easy Auth; keep them on loopback. See README.md for Azure settings and optional OAuth configuration.", "IsEncrypted": false, "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", @@ -9,6 +9,8 @@ "EPP_PROVIDER_NAME": "", "EPP_PROVIDER_ENDPOINT": "https:///", + "EPP_PROVIDER_AUTH_MODE": "apiKey", + "EPP_PROVIDER_JWT_ENABLED": "false", "EPP_PROVIDER_TIMEOUT_MS": "1500", "KEY_VAULT_URL": "https://.vault.azure.net/" diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index 735a639..4c3bc4e 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -84,7 +84,8 @@ ObjectResult Reply(int status, object body) Channel: channel, MessageId: clientRequestId, CorrelationId: correlationId, - Locale: context.Locale); + Locale: context.Locale, + TextToVoice: context.TextToVoice); // A nonce acknowledges delivery, not just decryption. Wait for the bounded provider call. var result = await _engine.DispatchAsync(dispatch, requestId); diff --git a/dotnet/Program.cs b/dotnet/Program.cs index 678961b..1a67eb0 100644 --- a/dotnet/Program.cs +++ b/dotnet/Program.cs @@ -16,9 +16,11 @@ .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Keep these registrations aligned with the adapter source files included in the project. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/dotnet/README.md b/dotnet/README.md index 2a45212..1dbabbb 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -3,6 +3,9 @@ Implements the shared [contract](../docs/CONTRACT.md) with one dispatch engine and one selected provider per deployment. Target: .NET 8 isolated worker, Azure Functions v4. +See the [auth gates](../docs/CONTRACT.md#provider-authentication-gates) and +[voice setup](../docs/ONBOARDING.md#structured-voice-input). API keys remain the default; JWT lookup is off. + ## Setup and deployment 1. Follow [customer onboarding](../docs/ONBOARDING.md). Set `EPP_PROVIDER_NAME` to the selected @@ -57,6 +60,7 @@ Restart the host after edits. Configure local host storage other than Azurite se the emulator connection into Azure. Core Tools does not resolve Key Vault references locally; supply the local test PEM or base64 PEM directly. The [project](dotnet.csproj) excludes private local settings from publish output. +For HTTP-only local execution, the emulator storage setting can be omitted; offline tests do not use it. For Azure, configure the same application variables on the serving app/slot's **Environment variables → App settings** page and resolve the private PEM through a Key Vault reference. Key Vault provider @@ -74,8 +78,9 @@ authenticate SAS: anyone with the public key can encrypt a request, and a fixed Use incoming `mode: 2` or `mode: "evaluation"` as the generic shutter for every provider: platform authentication on Azure, handler validation and decryption run, but provider lookup, provider Key Vault reads and provider HTTP do not. No provider configuration or diagnostic environment flag is required. -Live requests forward the rendered message unchanged using the configured provider's API key and -await acceptance before returning the nonce; failures omit it. Acceptance is not handset delivery. +Live requests preserve caller-provided message/structured voice fields using the configured provider's +API key, or an SDK-acquired provider token when explicitly supported and enabled. They await acceptance +before returning the nonce; failures omit it. Acceptance is not handset delivery. Platform/key prerequisites and HTTP outcomes are defined in the [contract](../docs/CONTRACT.md#evaluation-generic-shutter). @@ -90,7 +95,12 @@ Platform/key prerequisites and HTTP outcomes are defined in the | [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/ProviderTokenAcquirer.cs](Src/ProviderTokenAcquirer.cs) | Opt-in Entra provider-token acquisition; never inbound token validation | | [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 branches to the shared pipeline. See [production limitations](../docs/CONTRACT.md#production-limitations) before production use. + +Only the selected provider needs credentials. If you remove unused adapter source files, also remove +their registrations in [Program.cs](Program.cs) and any tests importing those types. Keeping the files +is simpler; see [single-provider setup](../docs/ONBOARDING.md#single-provider-deployments). diff --git a/dotnet/Src/AppConfig.cs b/dotnet/Src/AppConfig.cs index 8c02454..abd5e1c 100644 --- a/dotnet/Src/AppConfig.cs +++ b/dotnet/Src/AppConfig.cs @@ -6,6 +6,8 @@ public sealed class AppConfig public string? ExpectedKeyId { get; init; } public string? ProviderName { get; init; } public string? ProviderEndpoint { get; init; } + public string ProviderAuthMode { get; init; } = "apiKey"; + public string ProviderJwtEnabled { get; init; } = "false"; // Keep the raw value; DispatchEngine owns timeout normalization. public string? ProviderTimeoutMs { get; init; } @@ -15,6 +17,8 @@ public sealed class AppConfig ExpectedKeyId = env.Get("EPP_ENCRYPTION_KEY_ID"), ProviderName = env.Get("EPP_PROVIDER_NAME")?.Trim().ToLowerInvariant(), ProviderEndpoint = env.Get("EPP_PROVIDER_ENDPOINT"), + ProviderAuthMode = env.Get("EPP_PROVIDER_AUTH_MODE")?.Trim() ?? "apiKey", + ProviderJwtEnabled = env.Get("EPP_PROVIDER_JWT_ENABLED")?.Trim() ?? "false", ProviderTimeoutMs = env.Get("EPP_PROVIDER_TIMEOUT_MS"), }; } \ No newline at end of file diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index 1163547..ebeceba 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -103,6 +103,7 @@ public sealed class DeliveryContext [JsonPropertyName("locale")] public string? Locale { get; set; } [JsonPropertyName("message")] public string? Message { get; set; } [JsonPropertyName("riskContext")] public JsonElement? RiskContext { get; set; } + public TextToVoice? TextToVoice { get; set; } [JsonIgnore] public bool IsComplete => !string.IsNullOrWhiteSpace(Nonce) @@ -122,6 +123,8 @@ public static DeliveryContext FromPayload(JsonElement payload) Extension = ReadString("extension"), Locale = ReadString("locale"), RiskContext = payload.TryGetProperty("riskContext", out var risk) ? risk.Clone() : null, + TextToVoice = payload.TryGetProperty("voice", out var voice) && voice.ValueKind == JsonValueKind.Object + && voice.TryGetProperty("text2voice", out var textToVoice) ? TextToVoice.FromPayload(textToVoice) : null, }; } } @@ -207,13 +210,16 @@ public sealed class DispatchEngine private readonly ISecretResolver _secrets; private readonly IHttpClientFactory _httpFactory; private readonly IEnv _env; + private readonly IProviderTokenAcquirer _tokens; - public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null) + public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null, + IProviderTokenAcquirer? tokens = null) { _registry = registry; _secrets = secrets; _httpFactory = httpFactory; _env = env ?? new ProcessEnv(); + _tokens = tokens ?? new ProviderTokenAcquirer(secrets); } public async Task DispatchAsync(DispatchRequest dispatch, string requestId) @@ -230,24 +236,38 @@ public async Task DispatchAsync(DispatchRequest dispatch, string if (!OutcomeMapper.DefaultChannels.Contains(channel)) return new DispatchResult(400, new { status = "error", provider = providerId, reason = "unsupported channel", requestId }); - if (manifest.Auth.Mode != "apiKey") - return new DispatchResult(502, FailBody(providerId, channel, "unsupported provider auth mode", dispatch, requestId)); + if (channel == "voice" && manifest.RequiresTextToVoice && dispatch.TextToVoice?.IsComplete != true) + return new DispatchResult(400, FailBody(providerId, channel, "incomplete voice context", dispatch, requestId)); - ProviderCredential credential; - try { credential = await ResolveCredentialAsync(manifest.Auth); } - catch { return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); } + var jwtEnabled = string.Equals(config.ProviderJwtEnabled, "true", StringComparison.OrdinalIgnoreCase); + if (!jwtEnabled && !string.Equals(config.ProviderJwtEnabled, "false", StringComparison.OrdinalIgnoreCase)) + return new DispatchResult(502, FailBody(providerId, channel, "invalid provider JWT setting", dispatch, requestId)); - var identityRequired = !string.IsNullOrEmpty(manifest.Auth.IdentityKeyVaultSecretName); - var credentialUnavailable = string.IsNullOrEmpty(credential.Secret) - || (identityRequired && string.IsNullOrEmpty(credential.Identity)); - if (credentialUnavailable) - return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); + var jwtRequired = string.Equals(config.ProviderAuthMode, "oauth2", StringComparison.OrdinalIgnoreCase); + if (manifest.Auth.Mode != "apiKey" + || (jwtRequired ? !jwtEnabled : !string.Equals(config.ProviderAuthMode, "apiKey", StringComparison.OrdinalIgnoreCase)) + || (jwtEnabled && !manifest.Auth.SupportsOAuth)) + return new DispatchResult(502, FailBody(providerId, channel, "unsupported provider auth mode", dispatch, requestId)); var endpoint = config.ProviderEndpoint; if (!IsHttpsEndpoint(endpoint)) return new DispatchResult(502, FailBody(providerId, channel, "provider endpoint invalid or not configured", dispatch, requestId)); var timeoutMs = NormalizeProviderTimeoutMs(config.ProviderTimeoutMs); + ProviderCredential credential; + try + { + credential = await ResolveCredentialAsync(manifest, config, jwtEnabled, jwtRequired, timeoutMs); + } + catch (OperationCanceledException) when (jwtRequired) + { + return new DispatchResult(504, FailBody(providerId, channel, "provider token acquisition timed out", dispatch, requestId)); + } + catch + { + return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); + } + try { var req = adapter.BuildRequest(channel, endpoint!, dispatch, credential, _env); @@ -256,8 +276,14 @@ public async Task DispatchAsync(DispatchRequest dispatch, string var (providerHttpStatus, success, body) = await SendAsync(req, timeoutMs); JsonElement json; - try { using var responseDocument = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); json = responseDocument.RootElement.Clone(); } - catch { using var emptyDocument = JsonDocument.Parse("{}"); json = emptyDocument.RootElement.Clone(); } + try { using var responseDocument = JsonDocument.Parse(body); json = responseDocument.RootElement.Clone(); } + catch (JsonException) + { + if (success) + return new DispatchResult(502, FailBody(providerId, channel, "invalid provider response", dispatch, requestId)); + using var emptyDocument = JsonDocument.Parse("{}"); + json = emptyDocument.RootElement.Clone(); + } var parsed = adapter.ParseResponse(providerHttpStatus, success, json); var outcome = OutcomeMapper.ResolveOutcome(manifest, parsed); @@ -284,11 +310,36 @@ public async Task DispatchAsync(DispatchRequest dispatch, string } } - private async Task ResolveCredentialAsync(AuthConfig auth) + private async Task ResolveCredentialAsync(ProviderManifest manifest, AppConfig config, + bool jwtEnabled, bool jwtRequired, int timeoutMs) { - var secret = await _secrets.ResolveAsync(auth.KeyVaultSecretName); - var identity = string.IsNullOrEmpty(auth.IdentityKeyVaultSecretName) ? string.Empty : await _secrets.ResolveAsync(auth.IdentityKeyVaultSecretName); - return new ProviderCredential("apiKey", Secret: secret, Identity: identity); + var credential = new ProviderCredential(jwtRequired ? "oauth2" : "apiKey"); + if (!jwtRequired) + { + var auth = manifest.Auth; + var secret = await _secrets.ResolveAsync(auth.KeyVaultSecretName); + var identityRequired = !string.IsNullOrEmpty(auth.IdentityKeyVaultSecretName); + var identity = identityRequired ? await _secrets.ResolveAsync(auth.IdentityKeyVaultSecretName) : null; + bool IsPresent(string? value) => auth.SupportsOAuth + ? ProviderCredential.IsHeaderSafeToken(value) : !string.IsNullOrEmpty(value); + if (!IsPresent(secret) || (identityRequired && !IsPresent(identity))) + throw new InvalidOperationException("provider credential unavailable"); + credential = credential with { Secret = secret, Identity = identity }; + } + if (!jwtEnabled) return credential; + + try + { + var token = await _tokens.AcquireAsync(ProviderTokenConfig.Read(_env, manifest.Id, config.ProviderEndpoint!, timeoutMs)); + if (!ProviderCredential.IsHeaderSafeToken(token)) + throw new InvalidOperationException("provider token unavailable"); + return credential with { Token = token }; + } + catch when (!jwtRequired) + { + // Optional token failure never changes the selected API-key method or triggers a resend. + return credential; + } } internal static int NormalizeProviderTimeoutMs(string? value) diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index e8f14c6..b067807 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace Epp.Otp; @@ -24,9 +25,33 @@ public sealed record DispatchRequest( string Channel, string MessageId, string? CorrelationId, - string? Locale); + string? Locale, + TextToVoice? TextToVoice = null); -public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null); +public sealed record TextToVoice(string? BeforePasswordText, string? Password, string? Language) +{ + [JsonIgnore] + public bool IsComplete => BeforePasswordText is not null + && !string.IsNullOrWhiteSpace(Password) && !string.IsNullOrWhiteSpace(Language); + + public static TextToVoice? FromPayload(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + string? ReadString(string name) => payload.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + return new(ReadString("beforePasswordText"), ReadString("password"), ReadString("language")); + } + + public override string ToString() => nameof(TextToVoice); +} + +public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, string? Token = null) +{ + public override string ToString() => nameof(ProviderCredential); + + internal static bool IsHeaderSafeToken(string? token) => + !string.IsNullOrEmpty(token) && token.All(c => c > ' ' && c < '\u007f'); +} public sealed record ProviderHttpRequest(string Url, string Method, Dictionary Headers, string Body); @@ -41,9 +66,11 @@ public sealed record ParsedResponse( public override string ToString() => nameof(ParsedResponse); } -public sealed record AuthConfig(string Mode, string? KeyVaultSecretName = null, string? IdentityKeyVaultSecretName = null); +public sealed record AuthConfig(string Mode, string? KeyVaultSecretName = null, string? IdentityKeyVaultSecretName = null, + bool SupportsOAuth = false); -public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDictionary ResponseMapping); +public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDictionary ResponseMapping, + bool RequiresTextToVoice = false); public sealed record DispatchResult(int HttpStatus, object Body); diff --git a/dotnet/Src/ProviderTokenAcquirer.cs b/dotnet/Src/ProviderTokenAcquirer.cs new file mode 100644 index 0000000..48b98a2 --- /dev/null +++ b/dotnet/Src/ProviderTokenAcquirer.cs @@ -0,0 +1,169 @@ +using Azure.Core; +using Azure.Identity; + +namespace Epp.Otp; + +public sealed record ProviderTokenConfig( + string ProviderId, + string Endpoint, + string TenantId, + string ClientId, + string Scope, + string? ManagedIdentityClientId, + string? ClientSecretName, + string? KeyVaultUrl, + string? VaultManagedIdentityClientId, + int TimeoutMs) +{ + public override string ToString() => nameof(ProviderTokenConfig); + + public static ProviderTokenConfig Read(IEnv env, string providerId, string endpoint, int timeoutMs) + { + if (env.Get("EPP_PROVIDER_CLIENT_SECRET") is not null) + throw new InvalidOperationException("plaintext provider client secret is not supported"); + if (env.Get("EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE") is not null) + throw new InvalidOperationException("provider token exchange audience override is not supported"); + + var config = new ProviderTokenConfig(providerId, endpoint, + env.Get("EPP_PROVIDER_TENANT_ID") ?? string.Empty, + env.Get("EPP_PROVIDER_CLIENT_ID") ?? string.Empty, + env.Get("EPP_PROVIDER_SCOPE") ?? string.Empty, + env.Get("EPP_PROVIDER_MI_CLIENT_ID"), + env.Get("EPP_PROVIDER_CLIENT_SECRET_NAME"), + env.Get("KEY_VAULT_URL"), env.Get("AZURE_CLIENT_ID"), timeoutMs); + config.CheckConfiguration(); + return config; + } + + // Settings checks, not JWT verification; the SDK acquires/caches tokens, and the provider verifies them. + internal void CheckConfiguration() + { + var hasIdentity = !string.IsNullOrEmpty(ManagedIdentityClientId); + var hasSecret = !string.IsNullOrEmpty(ClientSecretName); + var requiredSettings = new List { TenantId, ClientId, Scope, + hasIdentity ? ManagedIdentityClientId : ClientSecretName }; + if (!string.IsNullOrEmpty(VaultManagedIdentityClientId)) requiredSettings.Add(VaultManagedIdentityClientId); + var validSettings = requiredSettings.All(ProviderCredential.IsHeaderSafeToken) + && DispatchEngine.IsHttpsEndpoint(Endpoint) && TimeoutMs > 0 && TimeoutMs <= 2500; + + var validAuthority = validSettings + && TenantId.All(c => char.IsAsciiLetterOrDigit(c) || c == '-' || c == '.') + && !new[] { "common", "organizations", "consumers", "adfs" }.Contains(TenantId, StringComparer.OrdinalIgnoreCase) + && Scope.Length > "/.default".Length && Scope.EndsWith("/.default", StringComparison.Ordinal); + + var validCredentials = hasIdentity != hasSecret + && (!hasSecret || DispatchEngine.IsHttpsEndpoint(KeyVaultUrl)); + + if (!(validSettings && validAuthority && validCredentials)) + throw new InvalidOperationException("provider token configuration invalid"); + } +} + +public interface IProviderTokenAcquirer +{ + Task AcquireAsync(ProviderTokenConfig config); +} + +public sealed class ProviderTokenAcquirer : IProviderTokenAcquirer +{ + private readonly ISecretResolver _secrets; + private readonly ProviderCredentialFactory _factory; + private readonly object _gate = new(); + private CredentialEntry? _cached; + + public ProviderTokenAcquirer(ISecretResolver secrets) : this(secrets, new ProviderCredentialFactory()) { } + + internal ProviderTokenAcquirer(ISecretResolver secrets, ProviderCredentialFactory factory) + { + _secrets = secrets; + _factory = factory; + } + + public async Task AcquireAsync(ProviderTokenConfig config) + { + config.CheckConfiguration(); + using var timeout = new CancellationTokenSource(config.TimeoutMs); + var cancellation = timeout.Token; + string? secret = null; + if (!string.IsNullOrEmpty(config.ClientSecretName)) + { + // The existing resolver has no cancellation overload; bound how long this request waits. + secret = await _secrets.ResolveAsync(config.ClientSecretName).WaitAsync(cancellation); + if (string.IsNullOrWhiteSpace(secret)) + throw new InvalidOperationException("provider credential unavailable"); + } + + TokenCredential credential; + lock (_gate) + { + cancellation.ThrowIfCancellationRequested(); + if (_cached is null || _cached.Config != config || _cached.Secret != secret) + _cached = new CredentialEntry(config, secret, CreateCredential(config, secret)); + credential = _cached.Credential; + } + + // Reuse the SDK credential/cache, not a custom access-token cache or expiry scheduler. + var token = await credential.GetTokenAsync(new TokenRequestContext(new[] { config.Scope }), cancellation) + .AsTask().WaitAsync(cancellation); + cancellation.ThrowIfCancellationRequested(); + return RequireUsableToken(token); + } + + private TokenCredential CreateCredential(ProviderTokenConfig config, string? secret) + { + var budget = TimeSpan.FromMilliseconds(config.TimeoutMs); + if (secret is not null) + return _factory.CreateClientSecret(config, secret, + Configure(new ClientSecretCredentialOptions(), budget)); + + var identity = _factory.CreateManagedIdentity(config.ManagedIdentityClientId!, + Configure(new TokenCredentialOptions(), budget)); + return _factory.CreateClientAssertion(config, async cancellation => + { + // Entra exchanges the managed-identity assertion for a provider-scoped application token. + // Use the callback's current cancellation token; never capture a previous request's timeout. + var assertion = await identity.GetTokenAsync( + new TokenRequestContext(new[] { "api://AzureADTokenExchange/.default" }), cancellation) + .AsTask().WaitAsync(cancellation); + return RequireUsableToken(assertion); + }, Configure(new ClientAssertionCredentialOptions(), budget)); + } + + private static T Configure(T options, TimeSpan budget) where T : TokenCredentialOptions + { + options.AuthorityHost = AzureAuthorityHosts.AzurePublicCloud; + options.Diagnostics.IsLoggingEnabled = false; + options.Diagnostics.IsLoggingContentEnabled = false; + options.Diagnostics.IsAccountIdentifierLoggingEnabled = false; + options.Retry.MaxRetries = 0; + options.Retry.NetworkTimeout = budget; + return options; + } + + private static string RequireUsableToken(AccessToken token) + { + if (!ProviderCredential.IsHeaderSafeToken(token.Token) || token.ExpiresOn <= DateTimeOffset.UtcNow.AddSeconds(60)) + throw new InvalidOperationException("provider token unavailable"); + return token.Token; + } + + // One entry bounds credential retention; equality includes source/vault identity and resolved secret rotation. + private sealed record CredentialEntry(ProviderTokenConfig Config, string? Secret, TokenCredential Credential) + { + public override string ToString() => nameof(CredentialEntry); + } +} + +// This seam lets tests run both credential flows without any authentication HTTP. +internal class ProviderCredentialFactory +{ + public virtual TokenCredential CreateClientSecret(ProviderTokenConfig config, string secret, ClientSecretCredentialOptions options) => + new ClientSecretCredential(config.TenantId, config.ClientId, secret, options); + + public virtual TokenCredential CreateManagedIdentity(string clientId, TokenCredentialOptions options) => + new ManagedIdentityCredential(clientId, options); + + public virtual TokenCredential CreateClientAssertion(ProviderTokenConfig config, + Func> assertion, ClientAssertionCredentialOptions options) => + new ClientAssertionCredential(config.TenantId, config.ClientId, assertion, options); +} diff --git a/dotnet/Src/Providers/InfobipProvider.cs b/dotnet/Src/Providers/InfobipProvider.cs index 3fd5207..a8d4bf1 100644 --- a/dotnet/Src/Providers/InfobipProvider.cs +++ b/dotnet/Src/Providers/InfobipProvider.cs @@ -65,16 +65,21 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) { - string? messageId = null, statusName = null, statusDesc = null; - if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("messages", out var messages) && messages.ValueKind == JsonValueKind.Array && messages.GetArrayLength() > 0) + string? messageId = null, statusName = "UNKNOWN", statusDesc = null; + if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("messages", out var messages) + && messages.ValueKind == JsonValueKind.Array && messages.GetArrayLength() > 0 + && messages[0].ValueKind == JsonValueKind.Object) { var firstMessage = messages[0]; if (firstMessage.TryGetProperty("messageId", out var messageIdElement)) messageId = messageIdElement.ToString(); if (firstMessage.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Object) { - if (status.TryGetProperty("groupName", out var groupName)) statusName = groupName.GetString()?.ToUpperInvariant(); - else if (status.TryGetProperty("name", out var name)) statusName = name.GetString()?.ToUpperInvariant(); - if (status.TryGetProperty("description", out var description)) statusDesc = description.GetString(); + if (!status.TryGetProperty("groupName", out var value) || value.ValueKind == JsonValueKind.Null) + status.TryGetProperty("name", out value); + if (value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString())) + statusName = value.GetString()!.ToUpperInvariant(); + if (status.TryGetProperty("description", out var description) && description.ValueKind == JsonValueKind.String) + statusDesc = description.GetString(); } } return new ParsedResponse(ok, httpStatus, messageId, statusName, null, statusDesc); diff --git a/dotnet/Src/Providers/SinchProvider.cs b/dotnet/Src/Providers/SinchProvider.cs index 673b42f..1d43ee5 100644 --- a/dotnet/Src/Providers/SinchProvider.cs +++ b/dotnet/Src/Providers/SinchProvider.cs @@ -57,13 +57,18 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) { + static string? ReadString(JsonElement payload, string name) => + payload.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(value.GetString()) ? value.GetString() : null; string? id = null, desc = null; if (json.ValueKind == JsonValueKind.Object) { - if (json.TryGetProperty("id", out var idElement)) id = idElement.ToString(); - else if (json.TryGetProperty("callId", out var callIdElement)) id = callIdElement.ToString(); - if (json.TryGetProperty("text", out var textElement)) desc = textElement.GetString(); + id = ReadString(json, "id") ?? ReadString(json, "callId"); + if (id is null && json.TryGetProperty("_links", out var links) && links.ValueKind == JsonValueKind.Object + && links.TryGetProperty("self", out var self)) + id = self.ValueKind == JsonValueKind.Object ? ReadString(self, "href") : ReadString(links, "self"); + desc = ReadString(json, "text"); } - return new ParsedResponse(ok, httpStatus, id, ok ? "Dispatched" : null, null, desc); + return new ParsedResponse(ok, httpStatus, id, ok && id is not null ? "Dispatched" : "UNKNOWN", null, desc); } } diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index 7ffb5df..046514d 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -6,7 +6,7 @@ public sealed class SopranoProvider : IProviderAdapter { public ProviderManifest Manifest { get; } = new( Id: "soprano", - Auth: new AuthConfig("apiKey", KeyVaultSecretName: "soprano-api-key", IdentityKeyVaultSecretName: "soprano-api-id"), + Auth: new AuthConfig("apiKey", KeyVaultSecretName: "soprano-api-key", IdentityKeyVaultSecretName: "soprano-api-id", SupportsOAuth: true), ResponseMapping: new Dictionary { ["ENROUTE"] = Outcome.Continue, @@ -20,7 +20,8 @@ public sealed class SopranoProvider : IProviderAdapter ["FILTERED"] = Outcome.Fail, ["BLOCKED"] = Outcome.Block, ["default"] = Outcome.Fail, - }); + }, + RequiresTextToVoice: true); public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { @@ -28,17 +29,43 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc { ["Content-Type"] = "application/json", ["Accept"] = "application/json", - ["X-MEMS-API-ID"] = credential.Identity ?? string.Empty, - ["X-MEMS-API-Key"] = credential.Secret ?? string.Empty, }; - var body = new + var jwtRequired = string.Equals(credential.Mode, "oauth2", StringComparison.OrdinalIgnoreCase); + if (string.Equals(credential.Mode, "apiKey", StringComparison.OrdinalIgnoreCase)) { - text = dispatch.Message, - destination = dispatch.Destination.TrimStart('+'), - messageTypes = new[] { channel == "voice" ? "voice" : "sms" }, - correlationId = dispatch.CorrelationId ?? dispatch.MessageId, - shutterMode = false, + if (!ProviderCredential.IsHeaderSafeToken(credential.Identity) || !ProviderCredential.IsHeaderSafeToken(credential.Secret)) + throw new InvalidOperationException("provider credential unavailable"); + headers["X-MEMS-API-ID"] = credential.Identity!; + headers["X-MEMS-API-Key"] = credential.Secret!; + } + else if (!jwtRequired) + { + throw new InvalidOperationException("unsupported provider auth mode"); + } + if (ProviderCredential.IsHeaderSafeToken(credential.Token)) + headers["Authorization"] = "Bearer " + credential.Token; + else if (jwtRequired) + throw new InvalidOperationException("provider token unavailable"); + + var body = new Dictionary + { + ["destination"] = dispatch.Destination.TrimStart('+'), + ["messageTypes"] = new[] { channel == "voice" ? "voice" : "sms" }, + ["correlationId"] = dispatch.CorrelationId ?? dispatch.MessageId, + ["shutterMode"] = false, }; + if (channel == "voice") + { + var voice = dispatch.TextToVoice; + if (voice?.IsComplete != true) throw new InvalidOperationException("incomplete voice context"); + body["voice"] = new { text2voice = new { + beforePasswordText = voice.BeforePasswordText, password = voice.Password, language = voice.Language, + } }; + } + else + { + body["text"] = dispatch.Message; + } return new ProviderHttpRequest($"{endpoint.TrimEnd('/')}/messages/omnimsg", "POST", headers, JsonSerializer.Serialize(body)); } diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs index 79978ad..3eec61c 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -61,14 +61,17 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) { - string? refId = null, statusCode = null, statusDesc = null; + string? refId = null, statusCode = "UNKNOWN", statusDesc = null; if (json.ValueKind == JsonValueKind.Object) { - if (json.TryGetProperty("reference_id", out var referenceId)) refId = referenceId.GetString(); + if (json.TryGetProperty("reference_id", out var referenceId) && referenceId.ValueKind == JsonValueKind.String) + refId = referenceId.GetString(); if (json.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Object) { - if (status.TryGetProperty("code", out var code) && code.ValueKind == JsonValueKind.Number) statusCode = code.GetInt32().ToString(); - if (status.TryGetProperty("description", out var description)) statusDesc = description.GetString(); + if (status.TryGetProperty("code", out var code) && code.ValueKind is JsonValueKind.String or JsonValueKind.Number + && !string.IsNullOrWhiteSpace(code.ToString())) statusCode = code.ToString(); + if (status.TryGetProperty("description", out var description) && description.ValueKind == JsonValueKind.String) + statusDesc = description.GetString(); } } return new ParsedResponse(ok, httpStatus, refId, null, statusCode, statusDesc); diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index e78a06b..87ff67a 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -15,7 +15,10 @@ private static DispatchRequest Request(string channel = "sms") => [InlineData("voice")] public void SopranoUsesExactOmnimsgContract(string channel) { - var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", Request(channel), + var voice = new TextToVoice(" Your code is \n", "012345", "en-GB"); + var dispatch = Request(channel) with { TextToVoice = voice }; + Assert.True(new SopranoProvider().Manifest.RequiresTextToVoice); + var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", dispatch, new ProviderCredential("apiKey", "test-key", "test-id"), new TestEnv()); Assert.Equal("https://provider.example/cgpapi/messages/omnimsg", request.Url); Assert.Equal("POST", request.Method); @@ -24,15 +27,30 @@ public void SopranoUsesExactOmnimsgContract(string channel) Assert.Equal("test-key", request.Headers["X-MEMS-API-Key"]); Assert.Equal("application/json", request.Headers["Accept"]); Assert.Equal("application/json", request.Headers["Content-Type"]); - var expected = new + var expected = new Dictionary { - text = Request().Message, - destination = "15551234567", - messageTypes = new[] { channel }, - correlationId = "correlation-id", - shutterMode = false, + ["destination"] = "15551234567", + ["messageTypes"] = new[] { channel }, + ["correlationId"] = "correlation-id", + ["shutterMode"] = false, }; + if (channel == "voice") + expected["voice"] = new { text2voice = new { beforePasswordText = voice.BeforePasswordText, + password = voice.Password, language = voice.Language } }; + else expected["text"] = dispatch.Message; Assert.Equal(JsonSerializer.Serialize(expected), request.Body); + + // Header combinations run through the real handler; retain the adapter's direct safety guard. + foreach (var token in new string?[] { null, "token\r\nInjected: value" }) + { + Assert.Throws(() => new SopranoProvider().BuildRequest(channel, + "https://provider.example", dispatch, new ProviderCredential("oauth2", Token: token), new TestEnv())); + var optional = new SopranoProvider().BuildRequest(channel, "https://provider.example", dispatch, + new ProviderCredential("apiKey", "key", "id", token), new TestEnv()); + Assert.Equal(4, optional.Headers.Count); + Assert.DoesNotContain("Authorization", optional.Headers.Keys); + } + Assert.Equal(nameof(ProviderCredential), new ProviderCredential("oauth2", "key", "id", "token").ToString()); } [Fact] diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 6324883..11190dc 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; @@ -31,7 +32,7 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() entered.TrySetResult(); return release.Task.WaitAsync(cancellation); }; - var pending = rig.Invoke(channel: "voice"); + var pending = rig.Invoke(channel: "voice", deliveryOverrides: ValidVoiceContext()); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -43,23 +44,90 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() } AssertAccepted(await pending); using var body = JsonDocument.Parse(rig.Http.Body!); - Assert.Equal(Message, body.RootElement.GetProperty("text").GetString()); + AssertVoiceBody(body.RootElement); Assert.Equal(1, rig.Http.Calls); var log = Assert.Single(rig.Log.Messages); var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Correlation)))[..16].ToLowerInvariant(); Assert.Contains("CorrelationId=" + hash, log); - foreach (var value in new[] { Phone, "918273", Nonce, Correlation, "private-api-key", "private-api-id" }) + foreach (var value in new[] { Phone, "918273", Nonce, Correlation, "private-api-key", "private-api-id", + "012345", "en-GB", "Your code is" }) Assert.DoesNotContain(value, log); } [Fact] - public async Task FailedHttpCannotAcknowledgeAnAcceptedBodyOrLeakProviderText() + public async Task FailedHttpCannotAcknowledgeLeakProviderTextOrResendWithoutJwt() { using var rig = new HandlerRig(); - rig.Http.Respond = _ => Task.FromResult(Json(503, - JsonSerializer.Serialize(new { status = "ACCEPTED", description = PrivateError }))); - AssertFailure(rig, await rig.Invoke(), 502); - Assert.Equal(1, rig.Http.Calls); + rig.EnableOAuth("apiKey"); + foreach (var status in new[] { 401, 503 }) + { + var calls = rig.Http.Calls; + rig.Http.Respond = _ => Task.FromResult(Json(status, + JsonSerializer.Serialize(new { status = "ACCEPTED", description = PrivateError }))); + AssertFailure(rig, await rig.Invoke(), status == 401 ? 401 : 502); + Assert.Equal(calls + 1, rig.Http.Calls); + Assert.Equal("Bearer provider-token", rig.Http.Authorization); + Assert.Equal(("private-api-id", "private-api-key"), (rig.Http.ApiId, rig.Http.ApiKey)); + } + } + + [Fact] + public async Task AllProvidersRequireAcceptanceEvidenceAndPreserveFailedHttp() + { + var cases = new (IProviderAdapter Adapter, string[] Accepted, string[] Malformed)[] + { + (new InfobipProvider(), new[] { + "{\"messages\":[{\"status\":{\"groupName\":\"PENDING\"}}]}", + "{\"messages\":[{\"status\":{\"name\":\"ACCEPTED\"}}]}", + "{\"messages\":[{\"status\":{\"groupName\":null,\"name\":\"DELIVERED\"}}]}", + }, new[] { + "{\"messages\":{\"0\":{\"status\":{\"groupName\":\"PENDING\"}}}}", "{\"messages\":[null]}", + "{\"messages\":[{\"status\":[]}]}", "{\"messages\":[{\"status\":{\"name\":123}}]}", + }.Concat(new object[] { false, Array.Empty(), "", " " }.Select(groupName => + JsonSerializer.Serialize(new { messages = new[] { new { status = new { groupName, name = "PENDING" } } } }))).ToArray()), + (new TelesignProvider(), new object[] { 290, "290", 100, "100" }.Select(code => + JsonSerializer.Serialize(new { status = new { code } })).ToArray(), + new[] { "{\"status\":{}}", "{\"status\":[]}" }.Concat(new object[] { true, new[] { 290 }, new { }, "" } + .Select(code => JsonSerializer.Serialize(new { status = new { code } }))).ToArray()), + (new SinchProvider(), new[] { "{\"id\":\"batch-id\"}", "{\"callId\":\"call-id\"}", + "{\"_links\":{\"self\":\"/batches/batch-id\"}}", "{\"_links\":{\"self\":{\"href\":\"/calls/call-id\"}}}" }, new[] { + "{\"id\":\" \"}", "{\"callId\":123}", "{\"id\":{\"href\":\"/not-an-id\"}}", + "{\"_links\":{\"self\":{\"href\":123}}}", "{\"_links\":{\"self\":\" \"}}", "{\"status\":\"Dispatched\"}", + }), + (new SopranoProvider(), new[] { "{\"status\":\"ENROUTE\"}", "[{\"state\":\"ACCEPTED\"}]" }, + new[] { "{\"status\":false,\"state\":\"ACCEPTED\"}", "{\"status\":{},\"state\":\"ACCEPTED\"}" }), + }; + using var rig = new HandlerRig(); + foreach (var (adapter, accepted, malformed) in cases) + { + var provider = adapter.Manifest.Id; + rig.Env["EPP_PROVIDER_NAME"] = provider; + var rejected = new[] { "{}", "null", "[]" }.Concat(malformed) + .Concat(provider == "soprano" ? Array.Empty() : new[] { "[" + accepted[0] + "]" }).ToArray(); + foreach (var raw in rejected) + { + using var json = JsonDocument.Parse(raw); + var parsed = adapter.ParseResponse(200, true, json.RootElement); + Assert.True(parsed.Success); // Transport success is not acceptance. + if (provider != "soprano") Assert.Equal("UNKNOWN", parsed.ProviderStatusName ?? parsed.ProviderStatusCode); + Assert.Equal(Outcome.Fail, OutcomeMapper.ResolveOutcome(adapter.Manifest, parsed)); + } + var responses = new[] { "PRIVATE-UPSTREAM", "", "{" }.Concat(rejected).Select(raw => (200, raw, 502)) + .Append((200, accepted[0][..^1] + ",\"invalid\":NaN}", 502)) + .Concat(accepted.Select(raw => (201, raw, 200))) + .Concat(new[] { 401, 403, 429, 500 }.SelectMany(status => new[] { accepted[0], "PRIVATE-UPSTREAM" } + .Select(raw => (status, raw, status == 403 ? 401 : status == 500 ? 502 : status)))); + foreach (var (status, raw, expected) in responses) + { + var calls = rig.Http.Calls; + rig.Http.Respond = _ => Task.FromResult(Json(status, raw)); + var result = await rig.Invoke(); + if (expected == 200) AssertAccepted(result); + else AssertFailure(rig, result, expected); + Assert.Equal(calls + 1, rig.Http.Calls); + Assert.DoesNotContain("PRIVATE-UPSTREAM", JsonSerializer.Serialize(result.Value) + string.Join("\n", rig.Log.Messages)); + } + } } [Fact] @@ -76,15 +144,20 @@ public async Task ResponseBodyTimeoutCancelsWithoutRetryOrSuccessNonce() } [Fact] - public async Task MissingIdentityOrKeyFailsClosedBeforeHttp() + public async Task MissingOrUnsafeApiKeysFailBeforeOptionalTokensOrHttp() { using var rig = new HandlerRig(); - rig.Secrets.Identity = ""; - AssertFailure(rig, await rig.Invoke(), 502); - rig.Secrets.Identity = "private-api-id"; - rig.Secrets.Secret = ""; - AssertFailure(rig, await rig.Invoke(), 502); + rig.EnableOAuth("apiKey"); + foreach (var (key, id) in new[] { ("", "id"), ("key", ""), ("unsafe\r\nkey", "id"), ("key", " ") }) + { + rig.Secrets.Secret = key; + rig.Secrets.Identity = id; + AssertFailure(rig, await rig.Invoke(), 502); + } Assert.Equal(0, rig.Http.Calls); + Assert.DoesNotContain("oauth-client-secret", rig.Secrets.Names); + Assert.Empty(rig.Credentials.Credentials); + Assert.Empty(rig.Credentials.ManagedIdentityIds); } [Fact] @@ -108,12 +181,154 @@ public async Task EvaluationValidatesRealJweWithoutProviderConfiguration() using var rig = new HandlerRig(); rig.Env.Clear(); rig.Env["EPP_ENCRYPTION_KEY_ID"] = "configured-key-id"; - AssertAccepted(await rig.Invoke("evaluation", tenantId: "untrusted-body-tenant")); + AssertAccepted(await rig.Invoke("evaluation", channel: "voice", tenantId: "untrusted-body-tenant")); Assert.Equal("encryption_key_id_mismatch", Assert.Single(rig.Log.Entries, entry => entry.Level == LogLevel.Warning).Message); foreach (var value in new[] { Kid, "configured-key-id", Phone, "918273", Nonce, Correlation, "untrusted-body-tenant" }) Assert.DoesNotContain(value, string.Join("\n", rig.Log.Messages)); Assert.Equal((1, 0, 0), (rig.Keys.Calls, rig.Secrets.Calls, rig.Http.Calls)); + Assert.Empty(rig.Credentials.Credentials); + } + + [Theory] + [InlineData(null, null, 200, false)] + [InlineData(" aPiKeY ", " FaLsE ", 200, false)] + [InlineData("apiKey", "true", 200, true)] + [InlineData("oauth2", "false", 502, false)] + [InlineData("OAuth2", " TrUe ", 200, true)] + [InlineData("apiKey", "true", 502, false, "infobip")] + [InlineData("oauth2", "true", 502, false, "telesign")] + [InlineData("apiKey", "true", 502, false, "sinch")] + [InlineData("unknown", "false", 502, false)] + [InlineData("", "true", 502, false)] + [InlineData("apiKey", "", 502, false)] + [InlineData("oauth2", "yes", 502, false)] + public async Task AuthModeAndJwtGateControlHeadersWithoutChangingSmsOrVoice(string? mode, string? flag, + int status, bool hasToken, string provider = "soprano") + { + using var rig = new HandlerRig(); + rig.EnableOAuth(mode); + rig.Env["EPP_PROVIDER_NAME"] = provider; + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = flag; + var apiKey = !string.Equals(mode?.Trim(), "oauth2", StringComparison.OrdinalIgnoreCase); + if (!hasToken && status == 200) + { + // Disabled JWT must ignore even explicitly forbidden OAuth settings. + rig.Env["EPP_PROVIDER_TENANT_ID"] = "common"; + rig.Env["EPP_PROVIDER_CLIENT_SECRET"] = ""; + } + if (!apiKey) rig.Secrets.Secret = rig.Secrets.Identity = ""; + AssertAccepted(await rig.Invoke("evaluation", channel: "voice")); + Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls)); + Assert.Empty(rig.Credentials.Credentials); + Assert.Empty(rig.Credentials.ManagedIdentityIds); + foreach (var channel in new[] { "sms", "voice" }) + { + var response = await rig.Invoke(channel: channel, deliveryOverrides: ValidVoiceContext()); + if (status != 200) + { + AssertFailure(rig, response, status); + continue; + } + AssertAccepted(response); + Assert.Equal(hasToken ? "Bearer provider-token" : null, rig.Http.Authorization); + Assert.Equal(apiKey ? "private-api-id" : null, rig.Http.ApiId); + Assert.Equal(apiKey ? "private-api-key" : null, rig.Http.ApiKey); + using var body = JsonDocument.Parse(rig.Http.Body!); + if (channel == "voice") AssertVoiceBody(body.RootElement); + else + { + Assert.Equal(Message, body.RootElement.GetProperty("text").GetString()); + Assert.False(body.RootElement.TryGetProperty("voice", out _)); + } + Assert.Equal(channel, body.RootElement.GetProperty("messageTypes")[0].GetString()); + } + Assert.Equal(status == 200 ? 2 : 0, rig.Http.Calls); + if (hasToken) Assert.Equal(2, Assert.Single(rig.Credentials.Credentials).Requests.Count); + else Assert.Empty(rig.Credentials.Credentials); + var names = status != 200 ? Array.Empty() : apiKey + ? hasToken ? new[] { "soprano-api-key", "soprano-api-id", "oauth-client-secret" } + : new[] { "soprano-api-key", "soprano-api-id" } + : new[] { "oauth-client-secret" }; + Assert.Equal(names.Concat(names), rig.Secrets.Names); + Assert.Empty(rig.Credentials.ManagedIdentityIds); + Assert.DoesNotContain("provider-token", string.Join("\n", rig.Log.Messages)); + } + + [Fact] + public async Task VoiceCapabilityFailsBeforeCredentialsAndIgnoresOuterVoiceButNotOtherProviders() + { + using var rig = new HandlerRig(); + using var fixtures = ReadContractFixtures(); + var outerVoice = ValidVoiceContext().GetProperty("voice"); + rig.EnableOAuth(); + foreach (var changes in fixtures.RootElement.GetProperty("incompleteVoiceContexts").EnumerateArray()) + { + var response = await rig.Invoke(channel: "voice", deliveryOverrides: changes, outerVoice: outerVoice); + AssertFailure(rig, response, 400); + Assert.Equal(3, JsonSerializer.SerializeToElement(response.Value).EnumerateObject().Count()); + } + Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls)); + Assert.Empty(rig.Credentials.Credentials); + Assert.Empty(rig.Credentials.Identity.Requests); + rig.Env["EPP_PROVIDER_AUTH_MODE"] = "apiKey"; + rig.Env["EPP_PROVIDER_JWT_ENABLED"] = "false"; + var invalid = JsonSerializer.SerializeToElement(new { voice = new { text2voice = new { password = 12345 } } }); + foreach (var (provider, channel) in new[] { ("soprano", "sms"), ("sinch", "voice") }) + { + rig.Env["EPP_PROVIDER_NAME"] = provider; + rig.Http.Respond = _ => Task.FromResult(Json(201, "{\"status\":\"ACCEPTED\",\"callId\":\"call-id\"}")); + AssertAccepted(await rig.Invoke(channel: channel, deliveryOverrides: invalid)); + var body = JsonSerializer.Deserialize(rig.Http.Body!); + Assert.False(body.TryGetProperty("voice", out _)); + Assert.Equal(Message, (channel == "sms" ? body : body.GetProperty("ttsCallout")).GetProperty("text").GetString()); + } + Assert.Equal(2, rig.Http.Calls); + } + + [Theory] + [InlineData("apiKey")] + [InlineData("oauth2")] + public async Task TokenFailuresFallBackOnlyWhenOptionalWithoutLeakingOrResending(string mode) + { + using var rig = new HandlerRig(); + rig.EnableOAuth(mode); + var jwtRequired = mode == "oauth2"; + async Task Check(int requiredStatus) + { + var calls = rig.Http.Calls; + var result = await rig.Invoke().WaitAsync(TimeSpan.FromSeconds(5)); + if (jwtRequired) AssertFailure(rig, result, requiredStatus); + else + { + AssertAccepted(result); + Assert.Null(rig.Http.Authorization); + Assert.Equal("private-api-id", rig.Http.ApiId); + Assert.Equal("private-api-key", rig.Http.ApiKey); + } + Assert.Equal(calls + (jwtRequired ? 0 : 1), rig.Http.Calls); + } + rig.Env.Remove("EPP_PROVIDER_TENANT_ID"); + await Check(502); + Assert.Empty(rig.Credentials.Credentials); + Assert.DoesNotContain("oauth-client-secret", rig.Secrets.Names); + rig.Env["EPP_PROVIDER_TENANT_ID"] = "tenant.example"; + rig.Credentials.Respond = (_, _) => Task.FromException(new InvalidOperationException(PrivateError)); + await Check(502); + rig.Credentials.Respond = (_, _) => Task.FromResult(new AccessToken("unsafe\r\nheader", DateTimeOffset.UtcNow.AddMinutes(10))); + await Check(502); + rig.Env["EPP_PROVIDER_TIMEOUT_MS"] = "50"; + rig.Credentials.Respond = async (_, cancellation) => + { + await Task.Delay(Timeout.Infinite, cancellation); + throw new InvalidOperationException("unreachable"); + }; + await Check(504); + if (jwtRequired) Assert.All(rig.Secrets.Names, name => Assert.Equal("oauth-client-secret", name)); + Assert.Equal(3, rig.Credentials.Credentials.Sum(credential => credential.Requests.Count)); + Assert.Empty(rig.Credentials.ManagedIdentityIds); + foreach (var value in new[] { PrivateError, "unsafe", "private-oauth-secret", "private-api-key", "private-api-id" }) + Assert.DoesNotContain(value, string.Join("\n", rig.Log.Messages)); } [Fact] @@ -205,6 +420,18 @@ public async Task SharedJwePolicyPermitsOnlyRsaOaep256WithA256Gcm() private static JsonDocument ReadContractFixtures() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "fixtures", "contract.json"))); + private static JsonElement ValidVoiceContext() + { + using var fixtures = ReadContractFixtures(); + return JsonSerializer.SerializeToElement(new { voice = new { text2voice = fixtures.RootElement.GetProperty("textToVoice") } }); + } + + private static void AssertVoiceBody(JsonElement body) + { + Assert.False(body.TryGetProperty("text", out _)); + Assert.Equal(ValidVoiceContext().GetProperty("voice").GetRawText(), body.GetProperty("voice").GetRawText()); + } + private static void AssertAccepted(ObjectResult result) { Assert.Equal(200, result.StatusCode); @@ -221,7 +448,7 @@ private static void AssertFailure(HandlerRig rig, ObjectResult result, int statu Assert.Equal(error, body.GetProperty("error").GetString()); Assert.False(body.TryGetProperty("nonce", out _)); var output = body.GetRawText() + string.Join("\n", rig.Log.Messages); - foreach (var value in new[] { PrivateError, Phone, "918273", Nonce }) + foreach (var value in new[] { PrivateError, Phone, "918273", Nonce, "012345", "en-GB", "Your code is" }) Assert.DoesNotContain(value, output); } @@ -236,6 +463,7 @@ private sealed class HandlerRig : IDisposable public TestHttp Http { get; } = new(); public TestKeys Keys { get; } = new(); public CapturingLogger Log { get; } = new(); + public TestProviderCredentialFactory Credentials { get; } = new(); public HandlerRig() { Env = new TestEnv @@ -246,13 +474,23 @@ public HandlerRig() }; var registry = new ProviderRegistry(new IProviderAdapter[] { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() }); - _function = new SendOtp(new DispatchEngine(registry, Secrets, Http, Env), + _function = new SendOtp(new DispatchEngine(registry, Secrets, Http, Env, new ProviderTokenAcquirer(Secrets, Credentials)), new JweDecryptor(Keys), Env, Log); } + public void EnableOAuth(string? mode = "OAuth2") + { + Env["EPP_PROVIDER_AUTH_MODE"] = mode; + Env["EPP_PROVIDER_JWT_ENABLED"] = "true"; + Env["EPP_PROVIDER_TENANT_ID"] = "tenant.example"; + Env["EPP_PROVIDER_CLIENT_ID"] = "client-id"; + Env["EPP_PROVIDER_SCOPE"] = "api://provider/.default"; + Env["EPP_PROVIDER_CLIENT_SECRET_NAME"] = "oauth-client-secret"; + Env["KEY_VAULT_URL"] = "https://vault.example"; + } public async Task Invoke(object? mode = null, string channel = "sms", string? tenantId = null, Jose.JweAlgorithm algorithm = Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption encryption = Jose.JweEncryption.A256GCM, JsonElement? deliveryOverrides = null, - string? plaintext = null) + JsonElement? outerVoice = null, string? plaintext = null) { var context = new Dictionary { ["nonce"] = Nonce, ["phoneNumber"] = Phone, ["message"] = Message }; if (deliveryOverrides is { } changes) @@ -262,7 +500,7 @@ public async Task Invoke(object? mode = null, string channel = "sm return await InvokeRaw(JsonSerializer.Serialize(new { type = EnvelopeParser.EnvelopeType, tenantId, correlationId = Correlation, channel, mode = mode ?? "live", - ttlSeconds = 60, encryptedDeliveryContext = encrypted, + ttlSeconds = 60, encryptedDeliveryContext = encrypted, voice = outerVoice, })); } public async Task InvokeRaw(string body) @@ -271,6 +509,7 @@ public async Task InvokeRaw(string body) var request = new DefaultHttpContext().Request; request.Method = "POST"; request.ContentType = "application/json"; + request.Headers.Authorization = "Bearer caller-token-not-for-provider"; request.Body = stream; return Assert.IsAssignableFrom(await _function.Run(request)); } @@ -280,12 +519,14 @@ public async Task InvokeRaw(string body) private sealed class TestSecrets : ISecretResolver { public int Calls { get; private set; } + public List Names { get; } = new(); public string Secret { get; set; } = "private-api-key"; public string Identity { get; set; } = "private-api-id"; public Task ResolveAsync(string? name) { Calls++; - return Task.FromResult(name == "soprano-api-id" ? Identity : Secret); + Names.Add(name); + return Task.FromResult(name == "soprano-api-id" ? Identity : name == "oauth-client-secret" ? "private-oauth-secret" : Secret); } } @@ -293,12 +534,18 @@ private sealed class TestHttp : HttpMessageHandler, IHttpClientFactory { public int Calls { get; private set; } public string? Body { get; private set; } + public string? Authorization { get; private set; } + public string? ApiId { get; private set; } + public string? ApiKey { get; private set; } public Func> Respond { get; set; } = _ => Task.FromResult(Json(201, "{\"status\":\"ACCEPTED\"}")); public HttpClient CreateClient(string name) => new(this, disposeHandler: false); protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Calls++; + Authorization = request.Headers.Authorization?.ToString(); + ApiId = request.Headers.TryGetValues("X-MEMS-API-ID", out var ids) ? Assert.Single(ids) : null; + ApiKey = request.Headers.TryGetValues("X-MEMS-API-Key", out var keys) ? Assert.Single(keys) : null; Body = await request.Content!.ReadAsStringAsync(cancellationToken); return await Respond(cancellationToken); } diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs index 88e001c..75172e6 100644 --- a/dotnet/tests/EnvelopeTests.cs +++ b/dotnet/tests/EnvelopeTests.cs @@ -63,7 +63,10 @@ public void JweAuthenticatesOriginalProtectedHeaderBytes() var encodedHeader = Encode(Encoding.UTF8.GetBytes(header)); var key = RandomNumberGenerator.GetBytes(32); var iv = RandomNumberGenerator.GetBytes(12); - var plaintext = Encoding.UTF8.GetBytes("{\"nonce\":\"test-nonce\",\"phoneNumber\":\"+15551234567\",\"message\":\"message\"}"); + using var fixtures = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "fixtures", "contract.json"))); + var voiceFields = fixtures.RootElement.GetProperty("textToVoice"); + var plaintext = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { nonce = "test-nonce", + phoneNumber = "+15551234567", message = "message", voice = new { text2voice = voiceFields } })); var ciphertext = new byte[plaintext.Length]; var tag = new byte[16]; using var cipher = new AesGcm(key, tag.Length); @@ -75,6 +78,21 @@ public void JweAuthenticatesOriginalProtectedHeaderBytes() Assert.Equal("test-nonce", context.Nonce); Assert.True(context.IsComplete); Assert.False(JsonSerializer.SerializeToElement(context).TryGetProperty("IsComplete", out _)); + var voice = Assert.IsType(context.TextToVoice); + Assert.True(voice.IsComplete); + Assert.Equal(" Your code is \n", voice.BeforePasswordText); + Assert.Equal("012345", voice.Password); + Assert.Equal("en-GB", voice.Language); + Assert.Equal(nameof(TextToVoice), voice.ToString()); + Assert.False(JsonSerializer.SerializeToElement(voice).TryGetProperty("IsComplete", out _)); + foreach (var prefix in new[] { "", new string(' ', 1601) }) + Assert.True((voice with { BeforePasswordText = prefix }).IsComplete); + foreach (var changes in fixtures.RootElement.GetProperty("incompleteVoiceContexts").EnumerateArray()) + Assert.NotEqual(true, DeliveryContext.FromPayload(changes).TextToVoice?.IsComplete); + var numeric = TextToVoice.FromPayload(JsonSerializer.SerializeToElement(new { + beforePasswordText = "Your code is", password = 12345, language = "en-GB" })); + Assert.Null(numeric!.Password); + Assert.Null(new DispatchRequest("phone", "message", "sms", "id", null, null).TextToVoice); segments[0] = Encode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(JsonSerializer.Deserialize(header)))); Assert.NotEqual(encodedHeader, segments[0]); Assert.ThrowsAny(() => decryptor.Decrypt(string.Join(".", segments))); diff --git a/dotnet/tests/ProviderTokenTests.cs b/dotnet/tests/ProviderTokenTests.cs new file mode 100644 index 0000000..84a5387 --- /dev/null +++ b/dotnet/tests/ProviderTokenTests.cs @@ -0,0 +1,263 @@ +using Azure.Core; +using Azure.Identity; +using Xunit; + +namespace Epp.Otp.Tests; + +public class ProviderTokenTests +{ + private static ProviderTokenConfig Config() => new("soprano", "https://provider.example/cgpapi", + "tenant.example", "client-id", "api://provider/.default", null, "oauth-client-secret", + "https://vault.example", "vault-identity", 1500); + + [Fact] + public async Task ClientSecretUsesOneSdkCredentialAndRebuildsOnConfigurationOrSecretRotation() + { + var secrets = new TokenSecrets(); + var factory = new TestProviderCredentialFactory(); + var acquirer = new ProviderTokenAcquirer(secrets, factory); + var config = Config(); + Assert.Equal(nameof(ProviderTokenConfig), config.ToString()); + for (var i = 0; i < 2; i++) Assert.Equal("provider-token", await acquirer.AcquireAsync(config)); + Assert.Equal(2, Assert.Single(factory.Credentials).Requests.Count); // SDK, not a custom token cache. + Assert.Equal("resolved-secret", Assert.Single(factory.ClientSecrets)); + Assert.All(secrets.Names, name => Assert.Equal(config.ClientSecretName, name)); + Assert.Empty(factory.ManagedIdentityIds); + AssertOptions(Assert.Single(factory.Options), config.TimeoutMs); + Assert.All(factory.Credentials[0].Requests, request => Assert.Equal(config.Scope, Assert.Single(request.Context.Scopes))); + var changes = new[] + { + config with { Endpoint = "https://other-provider.example" }, + config with { ProviderId = "other-adapter" }, + config with { TenantId = "other-tenant.example" }, + config with { ClientId = "other-client" }, + config with { Scope = "api://other-provider/.default" }, + config with { ClientSecretName = "other-secret-name" }, + config with { KeyVaultUrl = "https://other-vault.example" }, + config with { VaultManagedIdentityClientId = "other-vault-identity" }, + config with { TimeoutMs = 2500 }, + config, + }; + foreach (var changed in changes) + { + var previous = factory.Credentials.Count; + await acquirer.AcquireAsync(changed); + Assert.Equal(previous + 1, factory.Credentials.Count); + AssertOptions(factory.Options.Last(), changed.TimeoutMs); + } + secrets.Value = "rotated-secret"; + await acquirer.AcquireAsync(config); + Assert.Equal(changes.Length + 2, factory.Credentials.Count); + Assert.Equal(secrets.Value, factory.ClientSecrets.Last()); + } + + [Fact] + public async Task FederationExchangesManagedIdentityAssertionForTheConfiguredProviderScope() + { + var secrets = new TokenSecrets(); + var factory = new TestProviderCredentialFactory(); + var acquirer = new ProviderTokenAcquirer(secrets, factory); + var config = Config() with { ClientSecretName = null, ManagedIdentityClientId = "federated-identity" }; + for (var i = 0; i < 2; i++) Assert.Equal("provider-token", await acquirer.AcquireAsync(config)); + Assert.Empty(secrets.Names); + Assert.Empty(factory.ClientSecrets); + Assert.Equal(config.ManagedIdentityClientId, Assert.Single(factory.ManagedIdentityIds)); + var provider = Assert.Single(factory.Credentials); + Assert.Equal(2, provider.Requests.Count); + Assert.Equal(2, factory.Identity.Requests.Count); + for (var i = 0; i < 2; i++) + { + Assert.Equal(config.Scope, Assert.Single(provider.Requests[i].Context.Scopes)); + Assert.Equal("api://AzureADTokenExchange/.default", Assert.Single(factory.Identity.Requests[i].Context.Scopes)); + Assert.True(provider.Requests[i].Cancellation.CanBeCanceled); + Assert.Equal(provider.Requests[i].Cancellation, factory.Identity.Requests[i].Cancellation); + } + Assert.NotEqual(provider.Requests[0].Cancellation, provider.Requests[1].Cancellation); + Assert.All(factory.Options, options => AssertOptions(options, config.TimeoutMs)); + await acquirer.AcquireAsync(config with { ManagedIdentityClientId = "replacement-identity" }); + Assert.Equal(2, factory.ManagedIdentityIds.Count); + Assert.Equal("replacement-identity", factory.ManagedIdentityIds.Last()); + await acquirer.AcquireAsync(Config()); + Assert.Single(factory.ClientSecrets); + Assert.Equal(3, factory.Credentials.Count); + } + + [Fact] + public async Task InvalidSettingsAndUnusableTokensFailWithoutFallback() + { + var secrets = new TokenSecrets(); + var factory = new TestProviderCredentialFactory(); + var acquirer = new ProviderTokenAcquirer(secrets, factory); + var config = Config(); + (config with { Scope = "resource/.default" }).CheckConfiguration(); // No URL restriction on the resource. + foreach (var invalid in new[] + { + config with { TenantId = "" }, + config with { TenantId = "COMMON" }, + config with { TenantId = "tenant/other" }, + config with { ClientId = " client" }, + config with { Scope = "/.default" }, + config with { Scope = "api://provider/user.read" }, + config with { Scope = "api://one/.default api://two/.default" }, + config with { ClientSecretName = null }, + config with { ManagedIdentityClientId = "identity" }, + config with { ClientSecretName = "secret\r\n" }, + config with { ClientSecretName = null, ManagedIdentityClientId = "identit\u00e9" }, + config with { VaultManagedIdentityClientId = "identity\u007f" }, + config with { KeyVaultUrl = null }, + config with { KeyVaultUrl = "http://vault.example" }, + config with { Endpoint = "http://provider.example" }, + config with { TimeoutMs = 0 }, + }) + await Assert.ThrowsAsync(() => acquirer.AcquireAsync(invalid)); + var env = new TestEnv + { + ["EPP_PROVIDER_TENANT_ID"] = config.TenantId, + ["EPP_PROVIDER_CLIENT_ID"] = config.ClientId, + ["EPP_PROVIDER_SCOPE"] = config.Scope, + ["EPP_PROVIDER_CLIENT_SECRET_NAME"] = config.ClientSecretName, + ["KEY_VAULT_URL"] = config.KeyVaultUrl, + ["AZURE_CLIENT_ID"] = config.VaultManagedIdentityClientId, + }; + Assert.Equal(config, ProviderTokenConfig.Read(env, "soprano", config.Endpoint, 1500)); + foreach (var name in new[] { "EPP_PROVIDER_CLIENT_SECRET", "EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE" }) + { + env[name] = ""; // Presence is forbidden even when empty. + var error = Assert.Throws(() => ProviderTokenConfig.Read(env, "soprano", config.Endpoint, 1500)); + Assert.Equal(name == "EPP_PROVIDER_CLIENT_SECRET" ? "plaintext provider client secret is not supported" + : "provider token exchange audience override is not supported", error.Message); + env.Remove(name); + } + Assert.Empty(secrets.Names); + Assert.Empty(factory.Credentials); + secrets.Value = " "; + await Assert.ThrowsAsync(() => acquirer.AcquireAsync(config)); + Assert.Empty(factory.Credentials); + secrets.Value = "resolved-secret"; + foreach (var invalid in new[] + { + new AccessToken("", DateTimeOffset.UtcNow.AddMinutes(10)), + new AccessToken("unsafe\r\nheader", DateTimeOffset.UtcNow.AddMinutes(10)), + new AccessToken("t\u00f6ken", DateTimeOffset.UtcNow.AddMinutes(10)), + new AccessToken("expired", DateTimeOffset.UtcNow.AddSeconds(-1)), + new AccessToken("nearly-expired", DateTimeOffset.UtcNow.AddSeconds(60)), + }) + { + factory.Respond = (_, _) => Task.FromResult(invalid); + var error = await Assert.ThrowsAsync(() => acquirer.AcquireAsync(config)); + Assert.Equal("provider token unavailable", error.Message); + } + Assert.Equal(5, Assert.Single(factory.Credentials).Requests.Count); + Assert.Empty(factory.ManagedIdentityIds); + } + + [Fact] + public async Task TimeoutBoundsUncancellableSecretResolutionAndBothSdkFlows() + { + Assert.Equal(1500, DispatchEngine.NormalizeProviderTimeoutMs(null)); + Assert.Equal(1500, DispatchEngine.NormalizeProviderTimeoutMs("invalid")); + Assert.Equal(2500, DispatchEngine.NormalizeProviderTimeoutMs("9999999999999")); + var stalledSecret = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secrets = new TokenSecrets { Pending = stalledSecret.Task }; + var factory = new TestProviderCredentialFactory(); + var acquirer = new ProviderTokenAcquirer(secrets, factory); + var config = Config() with { TimeoutMs = 50 }; + try + { + await Assert.ThrowsAnyAsync(() => acquirer.AcquireAsync(config).WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Empty(factory.Credentials); + } + finally { stalledSecret.TrySetResult("resolved-secret"); } + + secrets.Pending = null; + var stalledToken = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + factory.Respond = (_, _) => stalledToken.Task; + try + { + await Assert.ThrowsAnyAsync(() => acquirer.AcquireAsync(config).WaitAsync(TimeSpan.FromSeconds(5))); + Assert.True(Assert.Single(Assert.Single(factory.Credentials).Requests).Cancellation.IsCancellationRequested); + } + finally { stalledToken.TrySetResult(new AccessToken("unused", DateTimeOffset.UtcNow.AddMinutes(10))); } + + factory.Identity.Respond = async (_, cancellation) => + { + await Task.Delay(Timeout.Infinite, cancellation); + throw new InvalidOperationException("unreachable"); + }; + await Assert.ThrowsAnyAsync(() => acquirer.AcquireAsync( + config with { ClientSecretName = null, ManagedIdentityClientId = "identity" }).WaitAsync(TimeSpan.FromSeconds(5))); + Assert.True(Assert.Single(factory.Identity.Requests).Cancellation.IsCancellationRequested); + Assert.Equal(2, factory.Credentials.Count); + } + + private static void AssertOptions(TokenCredentialOptions options, int timeoutMs) + { + Assert.Equal(AzureAuthorityHosts.AzurePublicCloud, options.AuthorityHost); + Assert.False(options.Diagnostics.IsLoggingEnabled); + Assert.False(options.Diagnostics.IsLoggingContentEnabled); + Assert.False(options.Diagnostics.IsAccountIdentifierLoggingEnabled); + Assert.Equal(0, options.Retry.MaxRetries); + Assert.Equal(TimeSpan.FromMilliseconds(timeoutMs), options.Retry.NetworkTimeout); + } + + private sealed class TokenSecrets : ISecretResolver + { + public List Names { get; } = new(); + public string Value { get; set; } = "resolved-secret"; + public Task? Pending { get; set; } + public Task ResolveAsync(string? name) { Names.Add(name); return Pending ?? Task.FromResult(Value); } + } +} + +internal sealed class TestProviderCredentialFactory : ProviderCredentialFactory +{ + public List Credentials { get; } = new(); + public List ClientSecrets { get; } = new(); + public List ManagedIdentityIds { get; } = new(); + public List Options { get; } = new(); + public TestTokenCredential Identity { get; } = new((_, _) => + Task.FromResult(new AccessToken("identity-assertion", DateTimeOffset.UtcNow.AddMinutes(10)))); + public Func> Respond { get; set; } = (_, _) => + Task.FromResult(new AccessToken("provider-token", DateTimeOffset.UtcNow.AddMinutes(10))); + + public override TokenCredential CreateClientSecret(ProviderTokenConfig config, string secret, ClientSecretCredentialOptions options) + { + ClientSecrets.Add(secret); + Options.Add(options); + var credential = new TestTokenCredential((context, cancellation) => Respond(context, cancellation)); + Credentials.Add(credential); + return credential; + } + + public override TokenCredential CreateManagedIdentity(string clientId, TokenCredentialOptions options) + { + ManagedIdentityIds.Add(clientId); Options.Add(options); + return Identity; + } + + public override TokenCredential CreateClientAssertion(ProviderTokenConfig config, + Func> assertion, ClientAssertionCredentialOptions options) + { + Options.Add(options); + var credential = new TestTokenCredential(async (context, cancellation) => + { + Assert.Equal("identity-assertion", await assertion(cancellation)); + return await Respond(context, cancellation); + }); + Credentials.Add(credential); + return credential; + } +} + +internal sealed class TestTokenCredential(Func> respond) : TokenCredential +{ + public List<(TokenRequestContext Context, CancellationToken Cancellation)> Requests { get; } = new(); + public Func> Respond { get; set; } = respond; + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) => + throw new NotSupportedException("Use async acquisition"); + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + Requests.Add((requestContext, cancellationToken)); + return new(Respond(requestContext, cancellationToken)); + } +} \ No newline at end of file diff --git a/javascript/README.md b/javascript/README.md index 293e006..fab3d35 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -3,6 +3,9 @@ A Node.js Azure Function implementing the shared [contract](../docs/CONTRACT.md): one dispatch engine and one selected provider per deployment. API-specific behavior stays in registered adapters. +See the [auth gates](../docs/CONTRACT.md#provider-authentication-gates) and +[voice setup](../docs/ONBOARDING.md#structured-voice-input). API keys remain the default; JWT lookup is off. + ## Setup 1. Follow [customer onboarding](../docs/ONBOARDING.md). Choose a registered adapter and set @@ -56,6 +59,7 @@ do **not** automatically load this file. [AppConfig](src/functions/config.js) re once per call to `readConfig()`. Restart the host after changing settings. Configure any local host storage other than Azurite separately; do not copy a local emulator connection into Azure. Core Tools does not resolve Key Vault references locally; supply the local test PEM or base64 PEM directly. +For HTTP-only local execution, the emulator storage setting can be omitted; offline tests do not use it. Older private settings may contain `DEFAULT_PROVIDER`, `ENDPOINT_TIMEOUT_MS`, `REQUIRE_AUTH`, `EXPECTED_AUDIENCE`, `ISSUER_TENANT_ID`, `EUDB`, or per-provider `*_ENDPOINT` entries. Those do not @@ -79,15 +83,16 @@ Easy Auth authenticates and authorizes the caller before `POST /api/SendOtp`; th validates the envelope and decrypts the JWE, without parsing or echoing incoming `Authorization`. JWE does not authenticate SAS: anyone with the public key can encrypt a request, and a fixed nonce is not authentication. Request `mode`, `channel`, `ttlSeconds` and `tenantId` are request data, not -environment settings or sources of identity trust. The caller-rendered message is forwarded unchanged; -the endpoint does not guess a passcode. +environment settings or sources of identity trust. Caller-provided message/structured voice fields +are preserved; the endpoint does not guess a passcode or extract it from the message. For non-delivery validation, use incoming `mode: 2` or `mode: "evaluation"`. This generic shutter works for every provider without provider configuration, provider Key Vault reads or provider HTTP; platform authentication on Azure and handler decryption still run. No diagnostic environment flag is needed. See the [evaluation contract](../docs/CONTRACT.md#evaluation-generic-shutter) for authentication/key prerequisites. -Live requests use the configured provider's API key and await acceptance before returning the nonce. +Live requests use the configured provider's API key, or an SDK-acquired provider token when explicitly +supported and enabled, and await acceptance before returning the nonce. Acceptance is not handset delivery; failures omit the nonce, and timeouts must not trigger blind retries. The shared contract defines validation, HTTP outcomes and privacy-safe logging. @@ -98,6 +103,7 @@ retries. The shared contract defines validation, HTTP outcomes and privacy-safe | [src/functions/SendOtp.js](src/functions/SendOtp.js) | HTTP handler | | [src/functions/config.js](src/functions/config.js) | Shared deployment settings | | [src/functions/models.js](src/functions/models.js) | Delivery context, normalized `ParsedResponse`, and documented request objects | +| [src/functions/providerToken.js](src/functions/providerToken.js) | Opt-in Entra provider-token acquisition; never inbound token validation | | [src/functions/dispatch.js](src/functions/dispatch.js) | Envelope/JWE handling, registry and dispatch | | [src/functions/providers/](src/functions/providers/) | Adapter manifests and API-specific implementations | | [test/](test/) | Representative offline checks | @@ -107,3 +113,6 @@ register it in [src/functions/dispatch.js](src/functions/dispatch.js). Return a `parseResponse`; raw API-specific JSON stays inside that adapter. Keep credentials, options and status mapping with that adapter; the shared pipeline needs no provider-specific branches. See [production limitations](../docs/CONTRACT.md#production-limitations) before production use. + +The registry contains explicit lazy loaders, not eager imports. Only the selected adapter is loaded; +unused files can be omitted from deployment. See [single-provider setup](../docs/ONBOARDING.md#single-provider-deployments). diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js index 6b216a5..0c92b6f 100644 --- a/javascript/src/functions/config.js +++ b/javascript/src/functions/config.js @@ -13,6 +13,12 @@ class AppConfig { this.providerName = (env.EPP_PROVIDER_NAME || '').trim().toLowerCase(); this.providerEndpoint = env.EPP_PROVIDER_ENDPOINT || ''; this.providerTimeoutMs = env.EPP_PROVIDER_TIMEOUT_MS || ''; + const authValue = env.EPP_PROVIDER_AUTH_MODE === undefined ? 'apiKey' : env.EPP_PROVIDER_AUTH_MODE; + const authMode = typeof authValue === 'string' ? authValue.trim().toLowerCase() : ''; + this.providerAuthMode = authMode === 'apikey' ? 'apiKey' : authMode; + const jwtValue = env.EPP_PROVIDER_JWT_ENABLED === undefined ? 'false' : env.EPP_PROVIDER_JWT_ENABLED; + const jwtFlag = typeof jwtValue === 'string' ? jwtValue.trim().toLowerCase() : ''; + this.providerJwtEnabled = jwtFlag === 'true' ? true : jwtFlag === 'false' ? false : null; this.keyVaultUrl = (env.KEY_VAULT_URL || '').trim(); this.managedIdentityClientId = (env.AZURE_CLIENT_ID || '').trim(); this.env = env; @@ -23,4 +29,29 @@ class AppConfig { const readConfig = (env = process.env) => new AppConfig(env); -module.exports = { AppConfig, readConfig }; +// App settings use trimmed ASCII decimal digits, no sign, exponent, or hex. +function parseProviderTimeout(value) { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text || [...text].some((character) => character < '0' || character > '9')) return 1500; + const milliseconds = Number(text); + return milliseconds > 0 ? Math.min(milliseconds, 2500) : 1500; +} + +function isValidProviderUrl(value) { + if (typeof value !== 'string' || !value.toLowerCase().startsWith('https://')) return false; + for (const character of value) { + if (!character.trim() || character.charCodeAt(0) < 32 || character === '\\' || character === '#') return false; + } + const authority = value.slice('https://'.length).split('/')[0].split('?')[0]; + // URL normalizes empty userinfo and empty ports away; reject those in the original authority too. + if (!authority || authority.includes('@') || authority.endsWith(':')) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && !!url.hostname && !url.username && !url.password && !url.hash + && (!url.port || (Number(url.port) >= 1 && Number(url.port) <= 65535)); + } catch { + return false; + } +} + +module.exports = { AppConfig, readConfig, parseProviderTimeout, isValidProviderUrl }; diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index e33383e..b1f335d 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -8,8 +8,9 @@ const crypto = require('crypto'); const { compactDecrypt } = require('jose'); const { ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); -const { readConfig } = require('./config'); -const { DeliveryContext } = require('./models'); +const { readConfig, parseProviderTimeout, isValidProviderUrl } = require('./config'); +const { DeliveryContext, TextToVoice } = require('./models'); +const { ProviderTokenAcquirer, isSafeBearerToken } = require('./providerToken'); const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); @@ -131,6 +132,7 @@ function contextToDispatch(context, envelope, messageId) { messageId, correlationId: envelope.correlationId, locale: context.locale || undefined, + textToVoice: context.textToVoice ?? null, }; } @@ -143,20 +145,18 @@ const OUTCOME = Object.freeze({ const SECRET_CACHE_TIME_TO_LIVE_MILLISECONDS = 5 * 60 * 1000; // rotated secrets picked up within this window -const providerRegistry = new Map( - [ - require('./providers/infobip'), - require('./providers/sinch'), - require('./providers/soprano'), - require('./providers/telesign'), - ].map((providerModule) => [ - providerModule.manifest.id.toLowerCase(), - { manifest: providerModule.manifest, adapter: providerModule }, - ]), -); +const providerRegistry = new Map([ + ['infobip', () => require('./providers/infobip')], + ['sinch', () => require('./providers/sinch')], + ['soprano', () => require('./providers/soprano')], + ['telesign', () => require('./providers/telesign')], +]); function getProvider(providerId) { - return providerId ? providerRegistry.get(String(providerId).trim().toLowerCase()) || null : null; + const load = providerId ? providerRegistry.get(String(providerId).trim().toLowerCase()) : null; + if (!load) return null; + const adapter = load(); + return { manifest: adapter.manifest, adapter }; } let keyVaultSecretClient = null; @@ -175,7 +175,7 @@ function getKeyVaultSecretClient(config) { return keyVaultSecretClient; } -async function resolveSecretValue(keyVaultSecretName, config) { +async function resolveSecretValue(keyVaultSecretName, config, options) { if (!keyVaultSecretName) { return ''; } @@ -185,7 +185,7 @@ async function resolveSecretValue(keyVaultSecretName, config) { return cachedSecret.value; } - const secretValue = (await getKeyVaultSecretClient(config).getSecret(keyVaultSecretName)).value || ''; + const secretValue = (await getKeyVaultSecretClient(config).getSecret(keyVaultSecretName, options)).value || ''; secretCache.set(cacheKey, { value: secretValue, @@ -194,17 +194,39 @@ async function resolveSecretValue(keyVaultSecretName, config) { return secretValue; } -async function resolveProviderCredential(authConfiguration = {}, config) { - const { mode = 'apiKey' } = authConfiguration; - if (mode !== 'apiKey') throw new Error('unsupported provider authentication'); - - const [secret, identity] = await Promise.all([ - resolveSecretValue(authConfiguration.keyVaultSecretName, config), - authConfiguration.identityKeyVaultSecretName - ? resolveSecretValue(authConfiguration.identityKeyVaultSecretName, config) - : Promise.resolve(''), - ]); - return { mode: 'apiKey', secret, identity }; +const providerTokenAcquirer = new ProviderTokenAcquirer({ resolveSecretValue }); + +async function resolveProviderCredential(manifest, config) { + const { providerAuthMode: mode = 'apiKey', providerJwtEnabled: jwtEnabled = false } = config; + if (!['apiKey', 'oauth2'].includes(mode) || typeof jwtEnabled !== 'boolean' + || (mode === 'oauth2' && !jwtEnabled) || (jwtEnabled && manifest.supportsOAuth !== true)) { + throw new Error('unsupported provider authentication'); + } + + const credential = { mode, secret: '', identity: '', token: null }; + if (mode === 'apiKey') { + const auth = manifest.auth || {}; + [credential.secret, credential.identity] = await Promise.all([ + resolveSecretValue(auth.keyVaultSecretName, config), + resolveSecretValue(auth.identityKeyVaultSecretName, config), + ]); + const isValidCredential = manifest.supportsOAuth === true ? isSafeBearerToken : Boolean; + if (!isValidCredential(credential.secret) + || (auth.identityKeyVaultSecretName && !isValidCredential(credential.identity))) { + throw new Error('provider credential unavailable'); + } + } + if (jwtEnabled) { + try { + const token = await providerTokenAcquirer.acquire(config); + if (!isSafeBearerToken(token)) throw new Error('provider token unavailable'); + credential.token = token; + } catch (error) { + if (mode === 'oauth2') throw error; + // API-key authentication remains usable; never log token acquisition failures. + } + } + return credential; } // Status mappings may restrict HTTP success, but cannot turn failed HTTP into Continue. @@ -240,31 +262,6 @@ function outcomeToHttpStatus(outcome, providerHttpStatus) { } } -// App settings use one grammar: trimmed ASCII decimal digits, no sign, exponent, or hex. -function parseProviderTimeout(value) { - const text = typeof value === 'string' ? value.trim() : ''; - if (!text || [...text].some((character) => character < '0' || character > '9')) return 1500; - const milliseconds = Number(text); - return milliseconds > 0 ? Math.min(milliseconds, 2500) : 1500; -} - -function isValidProviderUrl(value) { - if (typeof value !== 'string' || !value.toLowerCase().startsWith('https://')) return false; - for (const character of value) { - if (!character.trim() || character.charCodeAt(0) < 32 || character === '\\' || character === '#') return false; - } - const authority = value.slice('https://'.length).split('/')[0].split('?')[0]; - // URL normalizes empty userinfo and empty ports away; reject those in the original authority too. - if (!authority || authority.includes('@') || authority.endsWith(':')) return false; - try { - const url = new URL(value); - return url.protocol === 'https:' && !!url.hostname && !url.username && !url.password && !url.hash - && (!url.port || (Number(url.port) >= 1 && Number(url.port) <= 65535)); - } catch { - return false; - } -} - async function fetchWithTimeout(providerRequest, timeoutMilliseconds) { const abortController = new AbortController(); let timedOut = false; @@ -306,32 +303,28 @@ async function sendViaProvider(providerEntry, dispatch, options) { return { httpStatus: 400, body: { status: 'error', reason: 'unsupported channel', requestId } }; } + if (channel === 'voice' && manifest.requiresTextToVoice === true + && (!(dispatch.textToVoice instanceof TextToVoice) || !dispatch.textToVoice.isComplete)) { + return { httpStatus: 400, body: failBody(providerId, channel, 'incomplete voice context', dispatch, requestId) }; + } + const endpointBaseUrl = config.providerEndpoint; if (!isValidProviderUrl(endpointBaseUrl)) { return { httpStatus: 502, body: failBody(providerId, channel, 'provider endpoint missing or invalid', dispatch, requestId) }; } - let credential = null; + let providerRequest; try { - credential = await resolveProviderCredential(manifest.auth, config); - } catch { + const credential = await resolveProviderCredential(manifest, config); + providerRequest = adapter.buildRequest({ channel, endpoint: endpointBaseUrl, dispatch, credential, env: config.env }); + } catch (error) { + if (config.providerAuthMode === 'oauth2' && error?.name === 'TimeoutError') { + return { httpStatus: 504, body: failBody(providerId, channel, 'provider authentication timed out', dispatch, requestId) }; + } // Configuration and secret lookup failures share a generic failure response. - } - const identityRequired = !!manifest.auth?.identityKeyVaultSecretName; - const credentialUnavailable = !credential || !credential.secret - || (identityRequired && !credential.identity); - if (credentialUnavailable) { 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 (!isValidProviderUrl(providerRequest.url)) { return { httpStatus: 502, body: failBody(providerId, channel, 'provider request URL invalid', dispatch, requestId) }; } @@ -351,6 +344,9 @@ async function sendViaProvider(providerEntry, dispatch, options) { try { responseJson = JSON.parse(responseText); } catch { + if (providerResponse.ok) { + return { httpStatus: 502, body: failBody(providerId, channel, 'invalid provider response', dispatch, requestId) }; + } responseJson = {}; } @@ -377,7 +373,12 @@ async function sendViaProvider(providerEntry, dispatch, options) { } async function dispatchOtp(dispatch, { config = readConfig(), requestId } = {}) { - const providerEntry = getProvider(config.providerName); + let providerEntry; + try { + providerEntry = getProvider(config.providerName); + } catch { + return { httpStatus: 502, body: { status: 'error', reason: 'provider unavailable', requestId } }; + } if (!providerEntry) { return { httpStatus: 400, diff --git a/javascript/src/functions/models.js b/javascript/src/functions/models.js index ce863a5..64bbf84 100644 --- a/javascript/src/functions/models.js +++ b/javascript/src/functions/models.js @@ -23,21 +23,46 @@ const { inspect } = require('node:util'); * @property {string} messageId * @property {*} correlationId * @property {*} locale + * @property {TextToVoice|null} [textToVoice] */ +class TextToVoice { + constructor({ beforePasswordText, password, language }) { + this.beforePasswordText = typeof beforePasswordText === 'string' ? beforePasswordText : null; + this.password = typeof password === 'string' ? password : null; + this.language = typeof language === 'string' ? language : null; + } + + static fromPayload(payload) { + return payload && typeof payload === 'object' && !Array.isArray(payload) + ? new TextToVoice(payload) : null; + } + + get isComplete() { + return typeof this.beforePasswordText === 'string' + && [this.password, this.language].every(value => typeof value === 'string' && value.trim().length > 0); + } + + [inspect.custom]() { return '[TextToVoice]'; } +} + class DeliveryContext { - constructor({ nonce, phoneNumber, message, extension, locale, riskContext }) { + constructor({ nonce, phoneNumber, message, extension, locale, riskContext, textToVoice = null }) { this.nonce = nonce; this.phoneNumber = phoneNumber; this.message = message; this.extension = extension; this.locale = locale; this.riskContext = riskContext; + this.textToVoice = textToVoice; } static fromPayload(payload) { - return payload && typeof payload === 'object' && !Array.isArray(payload) - ? new DeliveryContext(payload) : null; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + const voice = payload.voice; + const textToVoice = voice && typeof voice === 'object' && !Array.isArray(voice) + ? TextToVoice.fromPayload(voice.text2voice) : null; + return new DeliveryContext({ ...payload, textToVoice }); } get isComplete() { @@ -72,4 +97,4 @@ class ParsedResponse { [inspect.custom]() { return '[ParsedResponse]'; } } -module.exports = { DeliveryContext, ParsedResponse }; +module.exports = { DeliveryContext, TextToVoice, ParsedResponse }; diff --git a/javascript/src/functions/providerToken.js b/javascript/src/functions/providerToken.js new file mode 100644 index 0000000..ad5d8bc --- /dev/null +++ b/javascript/src/functions/providerToken.js @@ -0,0 +1,143 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +const { inspect } = require('node:util'); +const { AsyncLocalStorage } = require('node:async_hooks'); +const { ManagedIdentityCredential, ClientAssertionCredential, ClientSecretCredential, logger } = require('@azure/identity'); +const { parseProviderTimeout, isValidProviderUrl } = require('./config'); + +const TOKEN_EXCHANGE_SCOPE = 'api://AzureADTokenExchange/.default'; + +class ProviderTokenConfig { + constructor(config) { + const env = config.env; + this.providerName = config.providerName; + this.endpoint = config.providerEndpoint; + this.tenantId = env.EPP_PROVIDER_TENANT_ID ?? ''; + this.clientId = env.EPP_PROVIDER_CLIENT_ID ?? ''; + this.scope = env.EPP_PROVIDER_SCOPE ?? ''; + this.miClientId = env.EPP_PROVIDER_MI_CLIENT_ID ?? ''; + this.secretName = env.EPP_PROVIDER_CLIENT_SECRET_NAME ?? ''; + this.keyVaultUrl = config.keyVaultUrl; + this.vaultIdentityClientId = env.AZURE_CLIENT_ID ?? ''; + this.hasPlaintextSecret = Object.hasOwn(env, 'EPP_PROVIDER_CLIENT_SECRET'); + this.hasExchangeOverride = Object.hasOwn(env, 'EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE'); + } + + // Settings checks, not JWT verification; the SDK acquires/caches tokens, and the provider verifies them. + checkConfiguration() { + const requiredSettings = [this.tenantId, this.clientId, this.scope, this.miClientId || this.secretName]; + if (this.vaultIdentityClientId) requiredSettings.push(this.vaultIdentityClientId); + const validSettings = requiredSettings.every(isSafeBearerToken); + + const tenantCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789.-'; + const validAuthority = validSettings + && !['common', 'organizations', 'consumers', 'adfs'].includes(this.tenantId.toLowerCase()) + && [...this.tenantId.toLowerCase()].every((character) => tenantCharacters.includes(character)) + && this.scope.endsWith('/.default') && this.scope.length > '/.default'.length; + + const validCredentials = Boolean(this.miClientId) !== Boolean(this.secretName) + && (!this.secretName || isValidProviderUrl(this.keyVaultUrl)) + && !this.hasPlaintextSecret && !this.hasExchangeOverride; + + if (!(validSettings && validAuthority && validCredentials)) { + throw new Error('provider authentication configuration invalid'); + } + } + + [inspect.custom]() { return '[ProviderTokenConfig]'; } +} + +function isSafeBearerToken(token) { + return typeof token === 'string' && token.length > 0 + && [...token].every((character) => character.charCodeAt(0) > 32 && character.charCodeAt(0) < 127); +} + +function tokenValue(result) { + if (!result || !isSafeBearerToken(result.token) || !Number.isFinite(result.expiresOnTimestamp) + || result.expiresOnTimestamp <= Date.now() + 60000) { + throw new Error('provider token unavailable'); + } + return result.token; +} + +function createCredential(config, secret, getSignal) { + // Identity's exported logger is package-wide; never emit SDK exception details. + for (const level of ['error', 'warning', 'info', 'verbose']) logger[level].enabled = false; + const options = { + authorityHost: 'https://login.microsoftonline.com', + retryOptions: { maxRetries: 0 }, + loggingOptions: { logger: Object.assign(() => {}, { enabled: false }), + allowLoggingAccountIdentifiers: false, enableUnsafeSupportLogging: false }, + }; + if (config.miClientId) { + const identity = new ManagedIdentityCredential(config.miClientId, options); + return new ClientAssertionCredential(config.tenantId, config.clientId, async () => { + const abortSignal = getSignal(); + abortSignal.throwIfAborted(); + return tokenValue(await identity.getToken(TOKEN_EXCHANGE_SCOPE, { abortSignal })); + }, options); + } + return new ClientSecretCredential(config.tenantId, config.clientId, secret, options); +} + +class ProviderTokenAcquirer { + #entry; + #signals = new AsyncLocalStorage(); + #resolveSecret; + #credentialFactory; + + constructor({ resolveSecretValue, credentialFactory = createCredential }) { + this.#resolveSecret = resolveSecretValue; + this.#credentialFactory = credentialFactory; + } + + async acquire(config) { + const controller = new AbortController(); + let timer; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error('provider authentication timed out'); + error.name = 'TimeoutError'; + reject(error); + controller.abort(); + }, parseProviderTimeout(config.providerTimeoutMs)); + }); + try { + return await Promise.race([this.#acquire(config, controller.signal), deadline]); + } catch (error) { + const failure = new Error('provider token unavailable'); + failure.name = controller.signal.aborted ? 'TimeoutError' : 'Error'; + throw failure; + } finally { + clearTimeout(timer); + controller.abort(); + } + } + + async #acquire(config, signal) { + const auth = new ProviderTokenConfig(config); + auth.checkConfiguration(); + const secret = auth.secretName ? await this.#resolveSecret(auth.secretName, config, { abortSignal: signal }) : ''; + signal.throwIfAborted(); + if (auth.secretName && (typeof secret !== 'string' || !secret.trim())) throw new Error('provider secret unavailable'); + + // One credential entry; Azure Identity owns token caching and renewal. + if (!this.#entry || this.#entry.secret !== secret + || Object.keys(auth).some((key) => this.#entry.config[key] !== auth[key])) { + this.#entry = { config: auth, secret, + credential: this.#credentialFactory(auth, secret, () => this.#signals.getStore()) }; + } + const credential = this.#entry.credential; + const result = await this.#signals.run(signal, () => credential.getToken(auth.scope, { abortSignal: signal })); + signal.throwIfAborted(); + return tokenValue(result); + } + + [inspect.custom]() { return '[ProviderTokenAcquirer]'; } +} + +module.exports = { ProviderTokenConfig, ProviderTokenAcquirer, isSafeBearerToken }; \ No newline at end of file diff --git a/javascript/src/functions/providers/infobip.js b/javascript/src/functions/providers/infobip.js index b0b1010..5572fca 100644 --- a/javascript/src/functions/providers/infobip.js +++ b/javascript/src/functions/providers/infobip.js @@ -55,13 +55,17 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { } function parseResponse({ httpStatus, ok, json }) { - const firstMessage = json && json.messages && json.messages[0]; - const status = (firstMessage && firstMessage.status) || {}; + const messages = json && !Array.isArray(json) && json.messages; + const first = Array.isArray(messages) ? messages[0] : null; + const firstMessage = first && typeof first === 'object' && !Array.isArray(first) ? first : {}; + const status = firstMessage.status && typeof firstMessage.status === 'object' && !Array.isArray(firstMessage.status) + ? firstMessage.status : {}; + const value = status.groupName ?? status.name; return new ParsedResponse({ success: ok, providerHttpStatus: httpStatus, - providerMessageId: (firstMessage && firstMessage.messageId) || null, - providerStatusName: (status.groupName || status.name || '').toUpperCase() || null, + providerMessageId: firstMessage.messageId || null, + providerStatusName: typeof value === 'string' && value.trim() ? value.toUpperCase() : 'UNKNOWN', }); } diff --git a/javascript/src/functions/providers/sinch.js b/javascript/src/functions/providers/sinch.js index d519d12..bc918b7 100644 --- a/javascript/src/functions/providers/sinch.js +++ b/javascript/src/functions/providers/sinch.js @@ -55,12 +55,15 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { } function parseResponse({ httpStatus, ok, json }) { - const messageOrCallId = (json && (json.id || json.callId || json._links && json._links.self)) || null; + const payload = json && typeof json === 'object' && !Array.isArray(json) ? json : {}; + const self = payload._links && !Array.isArray(payload._links) ? payload._links.self : null; + const link = self && typeof self === 'object' && !Array.isArray(self) ? self.href : self; + const identifier = [payload.id, payload.callId, link].find(value => typeof value === 'string' && value.trim()) || null; return new ParsedResponse({ success: ok, providerHttpStatus: httpStatus, - providerMessageId: typeof messageOrCallId === 'string' ? messageOrCallId : (messageOrCallId && messageOrCallId.href) || null, - providerStatusName: ok ? 'Dispatched' : (json && (json.text || json.status)) || null, + providerMessageId: identifier, + providerStatusName: ok && identifier ? 'Dispatched' : 'UNKNOWN', }); } diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index de86805..fdd8568 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -4,10 +4,13 @@ 'use strict'; -const { ParsedResponse } = require('../models'); +const { ParsedResponse, TextToVoice } = require('../models'); +const { isSafeBearerToken } = require('../providerToken'); const manifest = { id: 'soprano', + supportsOAuth: true, + requiresTextToVoice: true, auth: { mode: 'apiKey', keyVaultSecretName: 'soprano-api-key', @@ -34,18 +37,37 @@ function buildRequest({ channel, endpoint, dispatch, credential }) { const headers = { 'Content-Type': 'application/json', Accept: 'application/json', - 'X-MEMS-API-ID': credential.identity, - 'X-MEMS-API-Key': credential.secret, }; + if (credential.mode === 'apiKey') { + if (!isSafeBearerToken(credential.identity) || !isSafeBearerToken(credential.secret)) { + throw new Error('provider credential unavailable'); + } + headers['X-MEMS-API-ID'] = credential.identity; + headers['X-MEMS-API-Key'] = credential.secret; + } else if (credential.mode !== 'oauth2') { + throw new Error('unsupported provider authentication'); + } + if (isSafeBearerToken(credential.token)) headers.Authorization = `Bearer ${credential.token}`; + else if (credential.mode === 'oauth2') throw new Error('provider token unavailable'); let destination = String(dispatch.destination || ''); while (destination.startsWith('+')) destination = destination.slice(1); const body = { - text: dispatch.message, destination, messageTypes: [channel === 'voice' ? 'voice' : 'sms'], correlationId: dispatch.correlationId || dispatch.messageId, shutterMode: false, }; + if (channel === 'voice') { + const voice = dispatch.textToVoice; + if (!(voice instanceof TextToVoice) || !voice.isComplete) throw new Error('incomplete voice context'); + body.voice = { text2voice: { + beforePasswordText: voice.beforePasswordText, + password: voice.password, + language: voice.language, + } }; + } else { + body.text = dispatch.message; + } return { url: `${base}/messages/omnimsg`, method: 'POST', headers, body: JSON.stringify(body) }; } diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index 5ba03a1..888dd74 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -69,12 +69,14 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { } function parseResponse({ httpStatus, ok, json }) { - const status = (json && json.status) || {}; + const payload = json && typeof json === 'object' && !Array.isArray(json) ? json : {}; + const status = payload.status && typeof payload.status === 'object' && !Array.isArray(payload.status) ? payload.status : {}; + const code = status.code; return new ParsedResponse({ success: ok, providerHttpStatus: httpStatus, - providerMessageId: (json && json.reference_id) || null, - providerStatusCode: status.code != null ? String(status.code) : null, + providerMessageId: payload.reference_id || null, + providerStatusCode: typeof code === 'number' || (typeof code === 'string' && code.trim()) ? String(code) : 'UNKNOWN', }); } diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 28d7f51..6186c28 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -2,14 +2,19 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const { SecretClient } = require('@azure/keyvault-secrets'); const { AppConfig, readConfig } = require('../src/functions/config'); -const { DeliveryContext, ParsedResponse } = require('../src/functions/models'); +const { DeliveryContext, ParsedResponse, TextToVoice } = require('../src/functions/models'); const fixtures = require('../../tests/fixtures/contract.json'); +const { ProviderTokenAcquirer } = require('../src/functions/providerToken'); const { inspect } = require('node:util'); const { dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus, - parseEnvelope, parseProviderTimeout, isValidProviderUrl, + parseEnvelope, parseProviderTimeout, isValidProviderUrl, contextToDispatch, } = require('../src/functions/dispatch'); const dispatch = { destination: '+15551234567', message: ' Your code is 918273.\n', channel: 'sms', messageId: 'message-id', correlationId: 'correlation-id' }; @@ -43,12 +48,118 @@ test('config uses the deployment provider, with no hardcoded fallback', async (t assert.equal(JSON.parse(init.body).body, dispatch.message); }); +test('isolated packages load only the selected adapter and fail closed when it cannot load', () => { + const run = async function () { + const assert = require('node:assert/strict'); + const Module = require('node:module'); + const path = require('node:path'); + const { CompactEncrypt } = require('jose'); + const [root, scenario] = process.argv.slice(1); + let handler, secrets = 0, sends = 0; + const loads = [], logs = []; + const originalLoad = Module._load; + Module._load = function (name, parent, ...args) { + if (name === '@azure/functions') return { app: { http: (_name, options) => { handler = options.handler; } } }; + if (name === '@azure/identity') return { ManagedIdentityCredential: class {} }; + if (name === '@azure/keyvault-secrets') return { SecretClient: class { + async getSecret() { secrets++; return { value: 'fixture-key' }; } + } }; + if (name.includes('/providers/')) loads.push(name); + if (scenario === 'broken' && parent?.filename === path.join(root, 'providers', 'soprano.js') + && name === '../providerToken') { + throw Object.assign(new Error('PRIVATE-selected-dependency'), { code: 'MODULE_NOT_FOUND' }); + } + return originalLoad.call(this, name, parent, ...args); + }; + global.fetch = async () => { sends++; return { ok: true, status: 201, + text: async () => JSON.stringify({ status: 'ENROUTE' }) }; }; + for (const key of Object.keys(process.env)) { + if (key.startsWith('EPP_') || ['AZURE_CLIENT_ID', 'KEY_VAULT_URL'].includes(key)) delete process.env[key]; + } + const { publicKey, privateKey } = require('node:crypto').generateKeyPairSync('rsa', { modulusLength: 2048 }); + Object.assign(process.env, { EPP_PROVIDER_NAME: ' SoPrAnO ', EPP_PROVIDER_ENDPOINT: 'https://provider.example', + KEY_VAULT_URL: 'https://fixture.vault.azure.net', + EPP_DECRYPTION_KEY_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }) }); + require(path.join(root, 'SendOtp.js')); + assert.equal(typeof handler, 'function'); + assert.deepEqual(loads, []); + const { dispatchOtp, getProvider } = require(path.join(root, 'dispatch.js')); + const config = require(path.join(root, 'config.js')).readConfig(); + const delivery = { nonce: 'fixture-nonce', phoneNumber: '+15551234567', message: 'fixture-message' }; + const encryptedDeliveryContext = await new CompactEncrypt(Buffer.from(JSON.stringify(delivery))) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }).encrypt(publicKey); + const invoke = (mode) => handler({ headers: { get: () => null }, text: async () => JSON.stringify({ + type: 'microsoft.mfa.otpDeliver.v1', channel: 1, mode, encryptedDeliveryContext }) }, + { log: (value) => logs.push(value), warn: (value) => logs.push(value) }); + const evaluation = await invoke(2); + assert.equal(evaluation.status, 200); + assert.equal(evaluation.jsonBody.nonce, delivery.nonce); + const request = { destination: delivery.phoneNumber, message: delivery.message, channel: 'sms' }; + for (const providerName of [undefined, '', 'unknown', '../models']) { + assert.equal(getProvider(providerName), null); + const result = await dispatchOtp(request, { config: { ...config, providerName } }); + assert.deepEqual([result.httpStatus, result.body.reason], [400, 'unknown provider']); + } + assert.deepEqual([loads, secrets, sends], [[], 0, 0]); + const result = await dispatchOtp(request, { config: { ...config, providerName: ' SoPrAnO ' } }); + const response = await invoke(1); + if (scenario === 'soprano') { + assert.equal(result.httpStatus, 200); + assert.equal(response.status, 200); + assert.equal(response.jsonBody.nonce, delivery.nonce); + assert.equal(getProvider(' SoPrAnO ').adapter, getProvider('soprano').adapter); + assert.deepEqual([secrets, sends], [2, 2]); + } else { + assert.deepEqual(result, { httpStatus: 502, + body: { status: 'error', reason: 'provider unavailable', requestId: undefined } }); + assert.equal(response.status, 502); + assert.equal(response.jsonBody.error, 'provider_delivery_failed'); + assert.equal(response.jsonBody.nonce, undefined); + assert.throws(() => getProvider('soprano'), { code: 'MODULE_NOT_FOUND' }); + assert.deepEqual([secrets, sends], [0, 0]); + } + assert.ok(loads.length > 0 && loads.every((name) => name === './providers/soprano')); + for (const value of ['PRIVATE', 'MODULE_NOT_FOUND', root, delivery.phoneNumber, delivery.message]) { + assert.equal(JSON.stringify([result, response, logs]).includes(value), false); + } + }; + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'epp-provider-')); + try { + const source = path.join(__dirname, '../src/functions'); + for (const scenario of ['soprano', 'missing', 'broken']) { + const root = path.join(temp, scenario); + fs.cpSync(source, root, { recursive: true, filter: (file) => path.basename(file) !== 'providers' }); + fs.mkdirSync(path.join(root, 'providers')); + if (scenario !== 'missing') fs.copyFileSync(path.join(source, 'providers/soprano.js'), path.join(root, 'providers/soprano.js')); + assert.deepEqual(fs.readdirSync(path.join(root, 'providers')), scenario === 'missing' ? [] : ['soprano.js']); + const child = spawnSync(process.execPath, ['-e', `(${run})().catch(error => { console.error(error); process.exitCode = 1; });`, root, scenario], { + cwd: root, encoding: 'utf8', timeout: 20000, + env: { ...process.env, NODE_PATH: require.resolve.paths('jose').join(path.delimiter) }, + }); + assert.equal(child.status, 0, `${scenario}: ${child.error || child.stderr || child.stdout}`); + } + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + test('request models preserve content and accept valid TTL boundaries', () => { const context = DeliveryContext.fromPayload({ nonce: 'test-nonce', phoneNumber: dispatch.destination, message: dispatch.message }); assert.ok(context instanceof DeliveryContext); assert.ok(context.isComplete); assert.equal(context.message, dispatch.message); assert.equal(inspect(context), '[DeliveryContext]'); + assert.equal(context.textToVoice, null); + const voiceContext = DeliveryContext.fromPayload({ ...context, voice: { text2voice: fixtures.textToVoice } }); + const voice = voiceContext.textToVoice; + assert.ok(voice instanceof TextToVoice && voice.isComplete); + assert.equal(inspect(voice), '[TextToVoice]'); + assert.deepEqual({ ...voice }, fixtures.textToVoice); + assert.equal(contextToDispatch(voiceContext, { channel: 2 }, 'id').textToVoice, voice); + assert.equal(TextToVoice.fromPayload({ ...fixtures.textToVoice, password: 12345 }).password, null); + for (const beforePasswordText of ['', ' '.repeat(1601)]) { + assert.ok(new TextToVoice({ ...fixtures.textToVoice, beforePasswordText }).isComplete); + } for (const payload of [null, [], 'text', 1]) assert.equal(DeliveryContext.fromPayload(payload), null); assert.equal(DeliveryContext.fromPayload({ nonce: 123, phoneNumber: 'phone', message: 'text' }).isComplete, false); assert.ok(parseEnvelope(envelope()).envelope); @@ -80,14 +191,23 @@ test('provider URLs and timeouts retain representative safety boundaries', () => assert.equal(parseProviderTimeout('9999'), 2500); }); -test('omnimsg preserves its API-key request and normalizes acceptance', () => { - const request = getProvider('soprano').adapter.buildRequest({ ...input, env: undefined, endpoint: `${input.endpoint}/cgpapi///` }); +test('omnimsg preserves its API-key request and rejects unsafe credentials', () => { + const { adapter, manifest } = getProvider('soprano'); + assert.equal(manifest.supportsOAuth, true); + assert.equal(manifest.requiresTextToVoice, true); + const request = adapter.buildRequest({ ...input, env: undefined, endpoint: `${input.endpoint}/cgpapi///` }); assert.equal(request.url, 'https://provider.example/cgpapi/messages/omnimsg'); assert.equal(request.method, 'POST'); assert.deepEqual(request.headers, { 'Content-Type': 'application/json', Accept: 'application/json', 'X-MEMS-API-ID': 'id', 'X-MEMS-API-Key': 'key' }); assert.deepEqual(JSON.parse(request.body), { text: dispatch.message, destination: '15551234567', messageTypes: ['sms'], correlationId: 'correlation-id', shutterMode: false }); + assert.deepEqual(adapter.buildRequest({ ...input, + credential: { ...input.credential, token: 'bad\r\nheader' } }).headers, request.headers); + for (const credential of [{ mode: 'unknown' }, { mode: 'oauth2', token: 123 }, + { secret: 'bad\r\nheader', token: 'fixture-token' }, { identity: null, token: 'fixture-token' }]) { + assert.throws(() => adapter.buildRequest({ ...input, credential: { ...input.credential, ...credential } })); + } const response = getProvider('soprano').adapter.parseResponse({ httpStatus: 201, ok: true, json: { id: 123, status: 'ENROUTE' } }); assert.deepEqual(response, new ParsedResponse({ success: true, providerHttpStatus: 201, @@ -135,6 +255,9 @@ test('response parsing and HTTP mapping fail closed, including malformed status/ const mapping = { ...manifest, responseMapping: { ...manifest.responseMapping, CHALLENGE: 'StepUp' } }; for (const [json, upstream, expected, status] of [ [{ status: 'ENROUTE' }, 201, 'Continue', 200], + [{}, 200, 'Fail', 502], + [null, 200, 'Fail', 502], + [{ ApiResponse: { StatusCode: 1000 } }, 200, 'Fail', 502], [{ status: 'UNKNOWN' }, 200, 'Fail', 502], [{ status: 'FILTERED' }, 200, 'Fail', 502], [{ status: false, state: 'ACCEPTED' }, 200, 'Fail', 502], @@ -178,3 +301,19 @@ test('missing key/identity and an unsafe final voice URL make zero HTTP calls', assert.equal(new Set(getSecret.mock.calls.map((call) => call.this)).size, 3); assert.equal(fetchMock.mock.callCount(), 0); }); + +test('other channels and providers ignore incomplete voice metadata', async (t) => { + t.mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'fixture-key' })); + const tokens = t.mock.method(ProviderTokenAcquirer.prototype, 'acquire', async () => 'fixture-token'); + const send = t.mock.method(global, 'fetch', async () => ({ ok: true, status: 201, + text: async () => JSON.stringify({ status: 'ENROUTE', messages: [{ status: { groupName: 'PENDING' } }] }) })); + const settings = { EPP_PROVIDER_NAME: 'soprano', EPP_PROVIDER_ENDPOINT: input.endpoint, + KEY_VAULT_URL: 'https://voice-test.vault.azure.net' }; + for (const [provider, channel] of [['soprano', 'sms'], ['infobip', 'voice']]) { + assert.equal((await dispatchOtp({ ...dispatch, channel, textToVoice: new TextToVoice({ password: 123 }) }, { + config: readConfig({ ...settings, EPP_PROVIDER_NAME: provider }) })).httpStatus, 200); + } + assert.equal(send.mock.callCount(), 2); + assert.equal(tokens.mock.callCount(), 0); + assert.equal(JSON.parse(send.mock.calls[0].arguments[1].body).voice, undefined); +}); diff --git a/javascript/test/provider-token.test.js b/javascript/test/provider-token.test.js new file mode 100644 index 0000000..c929465 --- /dev/null +++ b/javascript/test/provider-token.test.js @@ -0,0 +1,208 @@ +'use strict'; + +const { test, beforeEach, mock } = require('node:test'); +const assert = require('node:assert/strict'); +const { inspect } = require('node:util'); +const Module = require('node:module'); +const { readConfig } = require('../src/functions/config'); +const { SecretClient } = require('@azure/keyvault-secrets'); +const { TextToVoice } = require('../src/functions/models'); + +let state; +const accessToken = (token = 'fixture-provider-token') => ({ token, expiresOnTimestamp: Date.now() + 600000 }); +const invalidTokens = () => [null, accessToken(''), accessToken('bad\r\nheader'), { token: 'no-expiry' }, + ...['token\u0001', 'token\u007f', 't\u00f6ken'].map(accessToken), + { token: 'expired', expiresOnTimestamp: Date.now() - 1 }, { token: 'near-expiry', expiresOnTimestamp: Date.now() + 59000 }]; +function sdkCredential(kind) { + return class { + constructor(...args) { + this.args = args; + state.created.push({ kind, args, credential: this }); + } + async getToken(scope, options) { + state.calls.push({ kind, scope, options, credential: this }); + if (kind === 'assertion') assert.equal(await this.args[2](), 'fixture-assertion'); + return state.getToken(kind, scope, options); + } + }; +} +const sdk = { ManagedIdentityCredential: sdkCredential('mi'), ClientAssertionCredential: sdkCredential('assertion'), + ClientSecretCredential: sdkCredential('secret'), + logger: Object.fromEntries(['error', 'warning', 'info', 'verbose'].map(level => [level, { enabled: true }])) }; +// Exercise the real acquirer/factory against SDK stubs, never a CLI or live identity endpoint. +const originalLoad = Module._load; +const registration = mock.method(Module, '_load', function (name, ...args) { + return name === '@azure/identity' ? sdk : originalLoad.call(this, name, ...args); +}); +let ProviderTokenAcquirer; +let ProviderTokenConfig; +try { + ({ ProviderTokenAcquirer, ProviderTokenConfig } = require('../src/functions/providerToken')); +} finally { + registration.mock.restore(); +} +const { dispatchOtp, getProvider } = require('../src/functions/dispatch'); +const base = { EPP_PROVIDER_NAME: 'soprano', EPP_PROVIDER_ENDPOINT: 'https://provider.example/cgpapi', + EPP_PROVIDER_AUTH_MODE: 'oauth2', EPP_PROVIDER_JWT_ENABLED: 'true', EPP_PROVIDER_TENANT_ID: '11111111-2222-4333-8444-555555555555', + EPP_PROVIDER_CLIENT_ID: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', EPP_PROVIDER_SCOPE: 'api://provider/.default', + EPP_PROVIDER_CLIENT_SECRET_NAME: 'provider-client-secret', KEY_VAULT_URL: 'https://oauth-test.vault.azure.net' }; +const dispatch = { destination: '+15551234567', message: ' code 1 2 3 4 5 6.\n', channel: 'voice', + messageId: 'message-id', correlationId: 'correlation-id', + textToVoice: new TextToVoice(require('../../tests/fixtures/contract.json').textToVoice) }; +beforeEach(() => { + state = { created: [], calls: [], getToken: async kind => accessToken(kind === 'mi' ? 'fixture-assertion' : undefined) }; +}); + +test('auth settings fail closed before secrets, SDK construction or sends', async (t) => { + assert.equal(readConfig({}).providerAuthMode, 'apiKey'); + assert.equal(readConfig({}).providerJwtEnabled, false); + assert.equal(readConfig({ EPP_PROVIDER_JWT_ENABLED: ' TrUe ' }).providerJwtEnabled, true); + assert.equal(readConfig({ EPP_PROVIDER_JWT_ENABLED: ' FaLsE ' }).providerJwtEnabled, false); + assert.equal(readConfig({ EPP_PROVIDER_AUTH_MODE: ' APIkey ' }).providerAuthMode, 'apiKey'); + assert.equal(readConfig({ EPP_PROVIDER_AUTH_MODE: ' OAuTh2 ' }).providerAuthMode, 'oauth2'); + const resolveSecretValue = t.mock.fn(async () => assert.fail('unexpected secret resolution')); + const acquirer = new ProviderTokenAcquirer({ resolveSecretValue }); + assert.equal(inspect(acquirer), '[ProviderTokenAcquirer]'); + assert.equal(inspect(new ProviderTokenConfig(readConfig(base))), '[ProviderTokenConfig]'); + new ProviderTokenConfig(readConfig({ ...base, EPP_PROVIDER_SCOPE: 'resource/.default', + KEY_VAULT_URL: ` ${base.KEY_VAULT_URL} ` })).checkConfiguration(); // Scope need not be a URL; AppConfig still trims the vault URL. + for (const change of [ + ...['common', 'ORGANIZATIONS', 'consumers', 'AdFs', '', 'tenant/path'].map(EPP_PROVIDER_TENANT_ID => ({ EPP_PROVIDER_TENANT_ID })), + ...['', ' value', 'value ', 'va lue', 'val\u0001ue', 'val\u007fue', 'val\u00e9ue', 123] + .map(EPP_PROVIDER_CLIENT_ID => ({ EPP_PROVIDER_CLIENT_ID })), + { EPP_PROVIDER_TENANT_ID: 'tenant name' }, { EPP_PROVIDER_SCOPE: 'api://provider/\u00e9/.default' }, + { EPP_PROVIDER_MI_CLIENT_ID: 'bad\r\nheader', EPP_PROVIDER_CLIENT_SECRET_NAME: '' }, + { EPP_PROVIDER_CLIENT_SECRET_NAME: ' bad-secret' }, { AZURE_CLIENT_ID: 'bad identity' }, + ...['', 'http://vault.example', 'https://', 'https://user@vault.example', 'https://vault.example/#fragment', + 'https://vault.example:0'].map(KEY_VAULT_URL => ({ KEY_VAULT_URL })), + { EPP_PROVIDER_SCOPE: 'api://provider/read' }, + { EPP_PROVIDER_SCOPE: '/.default' }, + { EPP_PROVIDER_SCOPE: 'api://one/.default api://two/.default' }, { EPP_PROVIDER_SCOPE: ' api://provider/.default' }, + { EPP_PROVIDER_CLIENT_SECRET_NAME: '' }, { EPP_PROVIDER_MI_CLIENT_ID: 'fixture-mi' }, + ...['', 'unsupported'].flatMap(value => [ + { EPP_PROVIDER_CLIENT_SECRET: value }, { EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE: value }, + ]), + ]) { + await assert.rejects(acquirer.acquire(readConfig({ ...base, ...change })), { message: 'provider token unavailable' }); + } + const secrets = t.mock.method(SecretClient.prototype, 'getSecret', async () => assert.fail('unexpected Key Vault call')); + const tokens = t.mock.method(ProviderTokenAcquirer.prototype, 'acquire', async () => assert.fail('unexpected token acquisition')); + const send = t.mock.method(global, 'fetch', async () => assert.fail('unexpected provider send')); + for (const change of [...['unknown', '', null, false].map(EPP_PROVIDER_AUTH_MODE => ({ EPP_PROVIDER_AUTH_MODE })), + ...['', 'yes', true, null, 0].map(EPP_PROVIDER_JWT_ENABLED => ({ EPP_PROVIDER_AUTH_MODE: 'apiKey', EPP_PROVIDER_JWT_ENABLED })), + { EPP_PROVIDER_JWT_ENABLED: 'false' }, { EPP_PROVIDER_JWT_ENABLED: undefined }, + { EPP_PROVIDER_ENDPOINT: 'http://unsafe.example' }, + ...['apiKey', 'oauth2'].map(EPP_PROVIDER_AUTH_MODE => ({ EPP_PROVIDER_NAME: 'infobip', EPP_PROVIDER_AUTH_MODE }))]) { + const config = readConfig({ ...base, ...change }); + if (config.providerName !== 'soprano') assert.notEqual(getProvider(config.providerName).manifest.supportsOAuth, true); + assert.equal((await dispatchOtp(dispatch, { config })).httpStatus, 502); + } + assert.deepEqual([resolveSecretValue.mock.callCount(), secrets.mock.callCount(), tokens.mock.callCount(), + state.created.length, state.calls.length, send.mock.callCount()], [0, 0, 0, 0, 0, 0]); +}); + +test('client secret and federated MI use the SDK, fixed scopes, and one reusable credential entry', async (t) => { + let secret = 'fixture-secret-v1'; + const resolveSecretValue = t.mock.fn(async () => secret); + const acquirer = new ProviderTokenAcquirer({ resolveSecretValue }); + const config = readConfig(base); + assert.deepEqual(await Promise.all([acquirer.acquire(config), acquirer.acquire(readConfig({ ...base }))]), + ['fixture-provider-token', 'fixture-provider-token']); + assert.equal(state.created.length, 1); + assert.equal(state.calls.length, 2); // Tokens are requested from the reused SDK, not a custom token map. + assert.deepEqual(state.created[0].args.slice(0, 3), [base.EPP_PROVIDER_TENANT_ID, base.EPP_PROVIDER_CLIENT_ID, secret]); + assert.equal(state.calls[0].scope, base.EPP_PROVIDER_SCOPE); + assert.ok(resolveSecretValue.mock.calls[0].arguments[2].abortSignal instanceof AbortSignal); + await acquirer.acquire(readConfig({ ...base, EPP_PROVIDER_SCOPE: 'api://other/.default' })); + await acquirer.acquire(config); + assert.equal(state.created.length, 3); // Returning to old settings builds anew; only one entry survives. + secret = 'fixture-secret-v2'; + await acquirer.acquire(config); + assert.equal(state.created.length, 4); + const federated = { ...base, EPP_PROVIDER_CLIENT_SECRET_NAME: '', EPP_PROVIDER_MI_CLIENT_ID: 'fixture-mi' }; + const secretCalls = resolveSecretValue.mock.callCount(); + const beforeMi = state.created.length; + await Promise.all([acquirer.acquire(readConfig(federated)), acquirer.acquire(readConfig(federated))]); + assert.equal(resolveSecretValue.mock.callCount(), secretCalls); + assert.equal(state.created.length, beforeMi + 2); + assert.deepEqual(state.created.slice(-2).map(entry => entry.kind), ['mi', 'assertion']); + assert.equal(state.created.at(-2).args[0], 'fixture-mi'); + assert.deepEqual(state.created.at(-1).args.slice(0, 2), [base.EPP_PROVIDER_TENANT_ID, base.EPP_PROVIDER_CLIENT_ID]); + const calls = state.calls.slice(-4); + assert.equal(new Set(calls.map(call => call.options.abortSignal)).size, 2); + for (let index = 0; index < calls.length; index += 2) { + assert.equal(calls[index].options.abortSignal, calls[index + 1].options.abortSignal); + } + for (const call of calls) assert.equal(call.scope, call.kind === 'mi' ? 'api://AzureADTokenExchange/.default' : base.EPP_PROVIDER_SCOPE); + await acquirer.acquire(readConfig({ ...federated, EPP_PROVIDER_MI_CLIENT_ID: 'other-mi' })); + await acquirer.acquire(config); + assert.deepEqual(state.created.slice(-3).map(entry => entry.kind), ['mi', 'assertion', 'secret']); + for (const { args } of state.created) { + const options = args.at(-1); + assert.equal(options.authorityHost, 'https://login.microsoftonline.com'); + assert.equal(options.retryOptions.maxRetries, 0); + assert.equal(options.loggingOptions.logger.enabled, false); + assert.equal(options.loggingOptions.enableUnsafeSupportLogging, false); + } + assert.ok(Object.values(sdk.logger).every(log => log.enabled === false)); +}); + +test('invalid SDK tokens, failures and bounded auth timeouts make zero provider sends', async (t) => { + const secrets = t.mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'fixture-secret' })); + const send = t.mock.method(global, 'fetch', async () => assert.fail('unexpected provider send')); + for (const result of invalidTokens()) { + state.getToken = async () => result; + assert.equal((await dispatchOtp(dispatch, { config: readConfig(base) })).httpStatus, 502); + } + state.getToken = async () => { throw new Error('PRIVATE-SDK-ERROR'); }; + const failed = await dispatchOtp(dispatch, { config: readConfig(base) }); + assert.equal(failed.httpStatus, 502); + assert.ok(!JSON.stringify(failed).includes('PRIVATE')); + // A failed/invalid MI assertion cannot be replaced with a client secret or sent as the provider token. + const federated = { ...base, EPP_PROVIDER_CLIENT_SECRET_NAME: '', EPP_PROVIDER_MI_CLIENT_ID: 'fixture-mi' }; + for (const getToken of [async () => { throw new Error('PRIVATE-MI-ERROR'); }, async () => accessToken('')]) { + state.getToken = getToken; + const secretCalls = secrets.mock.callCount(); + assert.equal((await dispatchOtp(dispatch, { config: readConfig(federated) })).httpStatus, 502); + assert.equal(secrets.mock.callCount(), secretCalls); + } + // Providers/SDKs can ignore cancellation: the caller still stops waiting at the normalized deadline. + const pending = []; + state.getToken = (_kind, _scope, { abortSignal }) => new Promise((_, reject) => pending.push({ abortSignal, reject })); + for (const settings of [base, federated]) { + const result = await dispatchOtp(dispatch, { config: readConfig({ ...settings, EPP_PROVIDER_TIMEOUT_MS: ' 0005 ' }) }); + assert.equal(result.httpStatus, 504); + assert.equal(pending.at(-1).abortSignal.aborted, true); + pending.at(-1).reject(new Error('PRIVATE-LATE-ERROR')); + } + let secretSignal; + let releaseSecret; + const acquirer = new ProviderTokenAcquirer({ resolveSecretValue: (_name, _config, { abortSignal }) => { + secretSignal = abortSignal; + return new Promise(resolve => { releaseSecret = resolve; }); + } }); + const beforeSecretTimeout = state.created.length; + await assert.rejects(acquirer.acquire(readConfig({ ...base, EPP_PROVIDER_TIMEOUT_MS: '5' })), { name: 'TimeoutError' }); + assert.equal(secretSignal.aborted, true); + releaseSecret('late-secret'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(state.created.length, beforeSecretTimeout); + assert.equal(send.mock.callCount(), 0); + assert.ok(secrets.mock.calls.every(call => call.arguments[0] === 'provider-client-secret')); +}); + +test('invalid API keys fail before optional JWT acquisition or provider sends', async (t) => { + const secrets = t.mock.method(SecretClient.prototype, 'getSecret', async name => ({ value: `fixture-${name}` })); + const tokens = t.mock.method(ProviderTokenAcquirer.prototype, 'acquire', async () => assert.fail('unexpected token acquisition')); + const send = t.mock.method(global, 'fetch', async () => assert.fail('unexpected provider send')); + let index = 0; + for (const name of ['soprano-api-id', 'soprano-api-key']) { + for (const value of ['', ' ', 'bad\r\nheader', null, 123]) { + secrets.mock.mockImplementation(async key => ({ value: key === name ? value : 'fixture-key' })); + const config = readConfig({ ...base, EPP_PROVIDER_AUTH_MODE: 'apiKey', + KEY_VAULT_URL: `https://missing-key-${index++}.vault.azure.net` }); + assert.equal((await dispatchOtp(dispatch, { config })).httpStatus, 502); + } + } + assert.deepEqual([tokens.mock.callCount(), state.calls.length, send.mock.callCount()], [0, 0, 0]); +}); \ No newline at end of file diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 0d79552..eb97483 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -6,6 +6,8 @@ const crypto = require('node:crypto'); const Module = require('node:module'); const { CompactEncrypt } = require('jose'); const { SecretClient } = require('@azure/keyvault-secrets'); +const { ProviderTokenAcquirer } = require('../src/functions/providerToken'); +const { getProvider, resolveOutcome } = require('../src/functions/dispatch'); const fixtures = require('../../tests/fixtures/contract.json'); // Capture the real handler; keys stay in memory and all external I/O is mocked. @@ -25,10 +27,14 @@ 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_LOG_PLAINTEXT', 'KEY_VAULT_URL', 'EPP_DECRYPTION_KEY_PEM', + 'EPP_PROVIDER_AUTH_MODE', 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_CLIENT_ID', 'EPP_PROVIDER_SCOPE', + 'EPP_PROVIDER_MI_CLIENT_ID', 'EPP_PROVIDER_CLIENT_SECRET_NAME', 'EPP_PROVIDER_CLIENT_SECRET', + 'EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE', 'EPP_PROVIDER_JWT_ENABLED']; let savedEnv; let fetchMock; let getSecret; +let getToken; let logs; let warnings; beforeEach(() => { @@ -39,6 +45,7 @@ beforeEach(() => { KEY_VAULT_URL: 'https://unit-test.vault.azure.net', EPP_PROVIDER_NAME: 'soprano', EPP_PROVIDER_ENDPOINT: 'https://provider.example/cgpapi/' }); getSecret = mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'PRIVATE-API-KEY' })); + getToken = mock.method(ProviderTokenAcquirer.prototype, 'acquire', async () => 'PRIVATE-PROVIDER-TOKEN'); fetchMock = mock.method(global, 'fetch', async () => ({ ok: true, status: 201, text: async () => JSON.stringify({ status: 'ENROUTE', id: 'PRIVATE-ID', description: 'PRIVATE-STATUS' }) })); }); @@ -70,7 +77,8 @@ function assertFailure(result, status, error = 'provider_delivery_failed') { assert.equal(result.status, status); assert.equal(result.jsonBody.error, error); assert.equal(result.jsonBody.nonce, undefined); - assert.doesNotMatch(JSON.stringify(result.jsonBody), /PRIVATE|accepted/); + for (const value of ['PRIVATE', 'accepted']) + assert.equal(JSON.stringify(result.jsonBody).includes(value), false); } test('shared invalid requests return matching safe reasons before provider I/O', async () => { @@ -142,15 +150,17 @@ test('JWE authenticates the original protected-header bytes, not reserialized JS test('evaluation decrypts without provider config or I/O and checks the advisory key ID', async () => { for (const key of ['EPP_PROVIDER_NAME', 'EPP_PROVIDER_ENDPOINT', 'KEY_VAULT_URL']) delete process.env[key]; + process.env.EPP_PROVIDER_AUTH_MODE = 'oauth2'; + process.env.EPP_PROVIDER_JWT_ENABLED = 'true'; for (const expectedKeyId of ['', 'PRIVATE-KID', 'private-kid']) { process.env.EPP_ENCRYPTION_KEY_ID = expectedKeyId; - const result = await invoke(await envelope({ mode: 'evaluation', provider: 'unknown' })); + const result = await invoke(await envelope({ channel: 2, mode: 'evaluation', provider: 'unknown' })); assert.equal(result.status, 200); assert.deepEqual(result.jsonBody, { nonce: delivery.nonce, correlationId: 'correlation-id', providerStatus: 'accepted' }); assert.equal(logs[0].evaluation, true); assert.deepEqual(warnings, expectedKeyId === 'private-kid' ? [['encryption_key_id_mismatch']] : []); } - assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); + assert.deepEqual([getSecret.mock.callCount(), getToken.mock.callCount(), fetchMock.mock.callCount()], [0, 0, 0]); }); test('SMS/voice preserve content and correlation without reflecting headers or logging PII', async () => { @@ -159,28 +169,79 @@ test('SMS/voice preserve content and correlation without reflecting headers or l 'x-ms-client-principal': Buffer.from(JSON.stringify({ claims: [{ typ: 'appid', val: 'FORGED-CALLER' }], })).toString('base64') }; - for (const [channel, name] of [[1, 'sms'], [2, 'voice']]) { + for (const [channel, name, mode, jwtEnabled, tokenError] of [[1, 'sms', 'apiKey', 'false'], [2, 'voice', 'apiKey', 'false'], + [1, 'sms', 'oauth2', 'true'], [2, 'voice', 'oauth2', 'true'], + [1, 'sms', 'apiKey', 'true'], [2, 'voice', 'apiKey', 'true'], + [1, 'sms', 'apiKey', 'true', 'Error'], [2, 'voice', 'apiKey', 'true', 'TimeoutError']]) { + process.env.EPP_PROVIDER_AUTH_MODE = mode; + process.env.EPP_PROVIDER_JWT_ENABLED = jwtEnabled; + getToken.mock.mockImplementation(async () => { + if (tokenError) throw Object.assign(new Error('PRIVATE-SDK-ERROR'), { name: tokenError }); + return 'PRIVATE-PROVIDER-TOKEN'; + }); const headers = channel === 1 ? {} : forgedHeaders; - const result = await invoke(await envelope({ channel, correlationId, provider: 'unknown' }), headers); + const voiceContext = { ...delivery, voice: { text2voice: fixtures.textToVoice } }; + const result = await invoke(await envelope({ channel, correlationId, provider: 'unknown' }, voiceContext), headers); assert.equal(result.status, 200); assert.deepEqual(result.jsonBody, { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }); const init = fetchMock.mock.calls.at(-1).arguments[1]; + assert.equal(init.headers.Authorization, jwtEnabled === 'true' && !tokenError ? 'Bearer PRIVATE-PROVIDER-TOKEN' : undefined); + assert.equal(init.headers['X-MEMS-API-Key'], mode === 'apiKey' ? 'PRIVATE-API-KEY' : undefined); + assert.equal(init.headers['X-MEMS-API-ID'], mode === 'apiKey' ? 'PRIVATE-API-KEY' : undefined); const sent = JSON.parse(init.body); - assert.deepEqual([sent.text, sent.messageTypes, sent.correlationId], [delivery.message, [name], correlationId]); + assert.deepEqual(sent, { destination: delivery.phoneNumber.slice(1), messageTypes: [name], correlationId, + shutterMode: false, ...(channel === 2 ? { voice: { text2voice: fixtures.textToVoice } } : { text: delivery.message }) }); 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.doesNotMatch(JSON.stringify(logs), /PRIVATE|918273|15551234567/); + for (const value of ['PRIVATE', '918273', '15551234567']) + assert.equal(JSON.stringify(logs).includes(value), false); + for (const value of Object.values(fixtures.textToVoice)) { + assert.equal(JSON.stringify([result.jsonBody, logs, warnings]).includes(value), false); + } const output = JSON.stringify([result.jsonBody, logs, warnings]); - assert.doesNotMatch(output, /FORGED/); + assert.equal(output.includes('FORGED'), false); for (const value of Object.values(forgedHeaders)) assert.equal(output.includes(value), false); } - assert.equal(fetchMock.mock.callCount(), 2); + assert.equal(fetchMock.mock.callCount(), 8); + assert.equal(getToken.mock.callCount(), 6); +}); + +test('incomplete encrypted voice fails closed even with a valid outer voice object', async () => { + process.env.EPP_PROVIDER_JWT_ENABLED = 'true'; + for (const mode of ['apiKey', 'oauth2']) { + process.env.EPP_PROVIDER_AUTH_MODE = mode; + for (const changes of fixtures.incompleteVoiceContexts) { + const result = await invoke(await envelope({ channel: 2, voice: { text2voice: fixtures.textToVoice } }, + { ...delivery, ...changes })); + assertFailure(result, 400); + assert.deepEqual(result.jsonBody, { error: 'provider_delivery_failed', correlationId: 'correlation-id', + requestId: result.jsonBody.requestId }); + for (const value of ['PRIVATE', '012345', 'en-GB', 'Your code is']) + assert.equal(JSON.stringify([logs, warnings]).includes(value), false); + } + } + assert.deepEqual([getSecret.mock.callCount(), getToken.mock.callCount(), fetchMock.mock.callCount()], [0, 0, 0]); }); -test('handler awaits the provider body and returns 502/429 without a nonce or retries', async () => { - for (const status of [500, 429]) { +test('outbound authentication failures return fixed 502/504 without a nonce or provider send', async () => { + process.env.EPP_PROVIDER_JWT_ENABLED = 'true'; + for (const [mode, errorName, status] of [['unknown', 'Error', 502], ['oauth2', 'Error', 502], ['oauth2', 'TimeoutError', 504]]) { + process.env.EPP_PROVIDER_AUTH_MODE = mode; + getToken.mock.mockImplementation(async () => { throw Object.assign(new Error('PRIVATE-SDK-ERROR'), { name: errorName }); }); + const result = await invoke(await envelope()); + assertFailure(result, status); + assert.deepEqual(result.jsonBody, { error: 'provider_delivery_failed', correlationId: 'correlation-id', + requestId: result.jsonBody.requestId }); + assert.equal(JSON.stringify([logs, warnings]).includes('PRIVATE'), false); + } + assert.deepEqual([getSecret.mock.callCount(), getToken.mock.callCount(), fetchMock.mock.callCount()], [0, 2, 0]); +}); + +test('handler awaits the provider body and returns failures without a nonce or retries, including dual-header 401', async () => { + process.env.EPP_PROVIDER_JWT_ENABLED = 'true'; + for (const status of [500, 429, 401]) { let release; let bodyStarted; const started = new Promise((resolve) => { bodyStarted = resolve; }); @@ -196,9 +257,66 @@ test('handler awaits the provider body and returns 502/429 without a nonce or re } finally { release(JSON.stringify({ status: 'ENROUTE', description: 'PRIVATE-STATUS' })); } - assertFailure(await pending, status === 500 ? 502 : 429); + assertFailure(await pending, status === 500 ? 502 : status); + } + assert.equal(fetchMock.mock.callCount(), 3); + assert.equal(getToken.mock.callCount(), 3); +}); + +test('all providers require acceptance evidence and preserve failed HTTP without a nonce', async () => { + const cases = [ + ['infobip', [ + '{"messages":[{"status":{"groupName":"PENDING"}}]}', + '{"messages":[{"status":{"name":"ACCEPTED"}}]}', + '{"messages":[{"status":{"groupName":null,"name":"DELIVERED"}}]}', + ], [ + '{"messages":{"0":{"status":{"groupName":"PENDING"}}}}', '{"messages":[null]}', + '{"messages":[{"status":[]}]}', '{"messages":[{"status":{"name":123}}]}', + ...[false, [], '', ' '].map(groupName => JSON.stringify({ messages: [{ status: { groupName, name: 'PENDING' } }] })), + ]], + ['telesign', [290, '290', 100, '100'].map(code => JSON.stringify({ status: { code } })), [ + '{"status":{}}', '{"status":[]}', + ...[true, [290], {}, ''].map(code => JSON.stringify({ status: { code } })), + ]], + ['sinch', ['{"id":"batch-id"}', '{"callId":"call-id"}', + '{"_links":{"self":"/batches/batch-id"}}', '{"_links":{"self":{"href":"/calls/call-id"}}}'], [ + '{"id":" "}', '{"callId":123}', '{"id":{"href":"/not-an-id"}}', + '{"_links":{"self":{"href":123}}}', '{"_links":{"self":" "}}', '{"status":"Dispatched"}', + ]], + ['soprano', ['{"status":"ENROUTE"}', '[{"state":"ACCEPTED"}]'], + ['{"status":false,"state":"ACCEPTED"}', '{"status":{},"state":"ACCEPTED"}']], + ]; + const request = await envelope(); + for (const [provider, accepted, malformed] of cases) { + process.env.EPP_PROVIDER_NAME = provider; + const { adapter, manifest } = getProvider(provider); + const rejectedJson = ['{}', 'null', '[]', ...malformed, + ...(provider === 'soprano' ? [] : [`[${accepted[0]}]`])]; + for (const raw of rejectedJson) { + const parsed = adapter.parseResponse({ httpStatus: 200, ok: true, json: JSON.parse(raw) }); + assert.equal(parsed.success, true); // Transport success is not acceptance. + if (provider !== 'soprano') assert.equal(parsed.providerStatusName || parsed.providerStatusCode, 'UNKNOWN'); + assert.equal(resolveOutcome(manifest, parsed), 'Fail', `${provider}: ${raw}`); + } + const responses = [ + ...['PRIVATE-UPSTREAM', '', '{', ...rejectedJson].map(raw => [200, raw, 502]), + [200, accepted[0].slice(0, -1) + ',"invalid":NaN}', 502], + ...accepted.map(raw => [201, raw, 200]), + ...[401, 403, 429, 500].flatMap(status => [accepted[0], 'PRIVATE-UPSTREAM'] + .map(raw => [status, raw, status === 403 ? 401 : status === 500 ? 502 : status])), + ]; + for (const [status, raw, expected] of responses) { + const calls = fetchMock.mock.callCount(); + fetchMock.mock.mockImplementation(async () => ({ ok: status < 300, status, text: async () => raw })); + const result = await invoke(request); + if (expected === 200) { + assert.equal(result.status, 200); + assert.equal(result.jsonBody.nonce, delivery.nonce); + } else assertFailure(result, expected); + assert.equal(fetchMock.mock.callCount(), calls + 1); + assert.equal(JSON.stringify([result.jsonBody, logs, warnings]).includes('PRIVATE-UPSTREAM'), false); + } } - assert.equal(fetchMock.mock.callCount(), 2); }); test('the real abort timer covers response-body reading: 504, no retry and no nonce', async () => { diff --git a/python/README.md b/python/README.md index de7afdb..4bc67f0 100644 --- a/python/README.md +++ b/python/README.md @@ -3,6 +3,9 @@ Implements the shared [contract](../docs/CONTRACT.md) with one dispatch engine and one selected provider per deployment. Target: Python 3.11, Azure Functions v4, Python v2 programming model. +See the [auth gates](../docs/CONTRACT.md#provider-authentication-gates) and +[voice setup](../docs/ONBOARDING.md#structured-voice-input). API keys remain the default; JWT lookup is off. + ## Setup and deployment 1. Follow [customer onboarding](../docs/ONBOARDING.md). Set `EPP_PROVIDER_NAME` to the selected @@ -57,6 +60,7 @@ use attributes such as `config.provider_name`, not dictionary key lookups. Resta settings change. Configure local host storage other than Azurite separately; do not copy the emulator connection into Azure. Core Tools does not resolve Key Vault references locally; supply the local test PEM or base64 PEM directly. +For HTTP-only local execution, the emulator storage setting can be omitted; offline tests do not use it. For Azure, set the same application variables on the serving app/slot's **Environment variables → App settings** page. Use a Key Vault reference for the private PEM. Provider secrets require managed @@ -74,8 +78,9 @@ authenticate SAS: anyone with the public key can encrypt a request, and a fixed Use incoming `mode: 2` or `mode: "evaluation"` as the generic shutter for every provider: platform authentication on Azure, handler validation and decryption run, but provider lookup, provider Key Vault reads and provider HTTP do not. No provider configuration or diagnostic environment flag is required. -Live requests forward the rendered message unchanged using the configured provider's API key and -await acceptance before returning the nonce; failures omit it. Acceptance is not handset delivery. +Live requests preserve caller-provided message/structured voice fields using the configured provider's +API key, or an SDK-acquired provider token when explicitly supported and enabled. They await acceptance +before returning the nonce; failures omit it. Acceptance is not handset delivery. Platform/key prerequisites and HTTP outcomes are defined in the [contract](../docs/CONTRACT.md#evaluation-generic-shutter). @@ -83,14 +88,18 @@ Platform/key prerequisites and HTTP outcomes are defined in the | Source | Purpose | |---|---| -| [function_app.py](function_app.py) | HTTP handler and adapter registration | +| [function_app.py](function_app.py) | HTTP handler and engine setup | | [src/config.py](src/config.py) | Shared deployment settings | | [src/models.py](src/models.py) | Envelope, delivery-context, dispatch and normalized `ParsedResponse` dataclasses | | [src/dispatch.py](src/dispatch.py) | Boundary validation, JWE, provider registry and outcome mapping | | [src/providers/](src/providers/) | Adapter manifests and API-specific implementations | +| [src/providers/__init__.py](src/providers/__init__.py) | Fixed provider-ID mapping; imports only the selected adapter | | [src/secrets.py](src/secrets.py) | Cached Key Vault access via managed identity | +| [src/provider_tokens.py](src/provider_tokens.py) | Opt-in Entra provider-token acquisition; never inbound token validation | -Add and register an adapter without adding provider-specific branches to the shared pipeline. +Add an adapter and its module/class mapping in [src/providers/__init__.py](src/providers/__init__.py) +without adding provider-specific branches to the shared pipeline. Unused adapter files need not be +deployed; see [single-provider setup](../docs/ONBOARDING.md#single-provider-deployments). Return `ParsedResponse` from `parse_response` using named fields; the engine reads attributes such as `parsed.provider_status_name`. Raw provider JSON remains local to the adapter, not a shared model hierarchy. See [production limitations](../docs/CONTRACT.md#production-limitations) before production use. diff --git a/python/function_app.py b/python/function_app.py index 61ab41d..7e8be0f 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -17,17 +17,13 @@ make_key_provider, parse_envelope, ) -from src.providers.infobip import InfobipProvider -from src.providers.sinch import SinchProvider -from src.providers.soprano import SopranoProvider -from src.providers.telesign import TelesignProvider from src.secrets import SecretResolver TAG = "[EPP]" app = func.FunctionApp() -_registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) +_registry = ProviderRegistry() _secrets = SecretResolver() _engine = DispatchEngine(_registry, _secrets) _key_provider = make_key_provider(os.environ) diff --git a/python/src/config.py b/python/src/config.py index c1fd9f6..ce583ef 100644 --- a/python/src/config.py +++ b/python/src/config.py @@ -10,6 +10,8 @@ class AppConfig: provider_name: str provider_endpoint: str | None provider_timeout_ms: str | None + provider_auth_mode: str + provider_jwt_enabled: bool | None env: Mapping[str, str] @@ -21,5 +23,7 @@ def read_config(env: Mapping[str, str] | None = None) -> AppConfig: provider_name=(env.get("EPP_PROVIDER_NAME") or "").strip().lower(), provider_endpoint=env.get("EPP_PROVIDER_ENDPOINT"), provider_timeout_ms=env.get("EPP_PROVIDER_TIMEOUT_MS"), + provider_auth_mode=env.get("EPP_PROVIDER_AUTH_MODE", "apiKey").strip().lower(), + provider_jwt_enabled={"true": True, "false": False}.get(env.get("EPP_PROVIDER_JWT_ENABLED", "false").strip().lower()), env=env, # Preserve raw adapter settings and the injected environment. ) \ No newline at end of file diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 47c7ccd..90fda99 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -9,7 +9,9 @@ from urllib3.exceptions import ReadTimeoutError from .config import read_config -from .models import DeliveryContext, DispatchRequest, Envelope, ParsedResponse +from .models import DeliveryContext, DispatchRequest, Envelope, ParsedResponse, TextToVoice +from .provider_tokens import ProviderTokenConfig, ProviderTokenError, default_token_acquirer, valid_bearer_token +from .providers import load_provider DEFAULT_TIMEOUT_MS = 1500 DEFAULT_CHANNELS = ["sms", "voice"] @@ -48,12 +50,14 @@ def to_http_status(outcome, provider_http_status): class ProviderRegistry: - def __init__(self, adapters): - self._by_id = {adapter.manifest["id"].lower(): adapter for adapter in adapters} + def __init__(self, adapters=None): + self._by_id = None if adapters is None else {adapter.manifest["id"].lower(): adapter for adapter in adapters} def get(self, provider_id): if not provider_id: return None + if self._by_id is None: + return load_provider(provider_id.lower()) return self._by_id.get(provider_id.lower()) @@ -179,6 +183,7 @@ def context_to_dispatch(context, envelope, message_id): message_id=message_id, correlation_id=envelope.correlation_id, locale=context.locale, + text_to_voice=context.text_to_voice, ) @@ -236,14 +241,18 @@ def _has_read_timeout(error): class DispatchEngine: - def __init__(self, registry, secrets, env=None): + def __init__(self, registry, secrets, env=None, token_acquirer=None): self.registry = registry self.secrets = secrets self.env = env if env is not None else os.environ + self.token_acquirer = default_token_acquirer if token_acquirer is None else token_acquirer def dispatch(self, dispatch, request_id): config = read_config(self.env) - adapter = self.registry.get(config.provider_name) + try: + adapter = self.registry.get(config.provider_name) + except Exception: + return 502, self._fail_body(config.provider_name, dispatch.channel, "provider unavailable", dispatch, request_id) if adapter is None: return 400, {"status": "error", "reason": "unknown provider", "requestId": request_id} @@ -257,15 +266,10 @@ def dispatch(self, dispatch, request_id): if channel not in DEFAULT_CHANNELS: return 400, {"status": "error", "provider": provider_id, "reason": "unsupported channel", "requestId": request_id} - auth = manifest["auth"] - if auth.get("mode") != "apiKey": - return 502, self._fail_body(provider_id, channel, "unsupported provider auth mode", dispatch, request_id) - try: - credential = self._resolve_credential(auth) - except Exception: - return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) - if not credential.get("secret") or (auth.get("identity_key_vault_secret_name") and not credential.get("identity")): - return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) + if channel == "voice" and manifest.get("requires_text_to_voice", False) is True and ( + not isinstance(dispatch.text_to_voice, TextToVoice) or not dispatch.text_to_voice.is_complete + ): + return 400, self._fail_body(provider_id, channel, "incomplete voice context", dispatch, request_id) endpoint = config.provider_endpoint if not endpoint: @@ -273,6 +277,15 @@ 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) + timeout_ms = _provider_timeout_ms(config.provider_timeout_ms) + try: + credential = self._resolve_credential(config, manifest, timeout_ms / 1000) + except ProviderTokenError as error: + status = 504 if error.timed_out else 502 + return status, self._fail_body(provider_id, channel, "provider token unavailable", dispatch, request_id) + except Exception: + return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) + try: provider_request = adapter.build_request(channel, endpoint, dispatch, credential, config.env) except Exception: @@ -280,7 +293,6 @@ 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) - timeout_ms = _provider_timeout_ms(config.provider_timeout_ms) response = None try: response = requests.request( @@ -294,12 +306,17 @@ def dispatch(self, dispatch, request_id): stream=True, # Own the response for cleanup if body reading fails. ) + ok = 200 <= response.status_code < 300 try: - body_json = response.json() + def reject_constant(_value): + raise ValueError("invalid JSON constant") + + body_json = response.json(parse_constant=reject_constant) except ValueError: + if ok: + return 502, self._fail_body(provider_id, channel, "invalid provider response", dispatch, request_id) body_json = {} - ok = 200 <= response.status_code < 300 parsed = adapter.parse_response(response.status_code, ok, body_json) outcome = resolve_outcome(manifest, parsed) http_status = to_http_status(outcome, parsed.provider_http_status or response.status_code) @@ -331,10 +348,36 @@ def dispatch(self, dispatch, request_id): except Exception: pass - def _resolve_credential(self, auth): - secret = self.secrets.resolve(auth.get("key_vault_secret_name")) - identity = self.secrets.resolve(auth.get("identity_key_vault_secret_name")) if auth.get("identity_key_vault_secret_name") else "" - return {"mode": "apiKey", "secret": secret, "identity": identity} + def _resolve_credential(self, config, manifest, timeout_seconds): + auth, mode, jwt_enabled = manifest["auth"], config.provider_auth_mode, config.provider_jwt_enabled + if (mode not in ("apikey", "oauth2") or jwt_enabled is None + or (jwt_enabled and not auth.get("supports_oauth")) + or (mode == "oauth2" and not jwt_enabled) + or (mode == "apikey" and auth.get("mode") != "apiKey")): + raise ValueError("unsupported provider auth configuration") + + credential = {"mode": "oauth2"} + if mode == "apikey": + secret = self.secrets.resolve(auth.get("key_vault_secret_name")) + identity_name = auth.get("identity_key_vault_secret_name") + identity = self.secrets.resolve(identity_name) if identity_name else "" + required = (secret, identity) if identity_name else (secret,) + if not all(valid_bearer_token(value) if auth.get("supports_oauth") else value for value in required): + raise ValueError("provider credential unavailable") + credential = {"mode": "apiKey", "secret": secret, "identity": identity} + + if jwt_enabled: + try: + token_config = ProviderTokenConfig.read(config, manifest["id"], timeout_seconds) + token = self.token_acquirer.acquire(token_config, self.secrets) + if not valid_bearer_token(token): + raise ProviderTokenError() + credential["token"] = token + except Exception: + if mode == "oauth2": + raise + # Only token failures are optional; required API keys were already checked. + return credential def _fail_body(self, provider, channel, reason, dispatch, request_id): return {"status": "failed", "outcome": "Fail", "provider": provider, "channel": channel, "reason": reason, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} diff --git a/python/src/models.py b/python/src/models.py index 8564af4..871a32d 100644 --- a/python/src/models.py +++ b/python/src/models.py @@ -12,6 +12,27 @@ class Envelope: encrypted_delivery_context: str +@dataclass(repr=False) +class TextToVoice: + before_password_text: str | None + password: str | None + language: str | None + + @classmethod + def from_payload(cls, payload: object) -> "TextToVoice | None": + if not isinstance(payload, dict): + return None + return cls(*(value if isinstance(value, str) else None for value in ( + payload.get("beforePasswordText"), payload.get("password"), payload.get("language"), + ))) + + @property + def is_complete(self) -> bool: + return isinstance(self.before_password_text, str) and all( + isinstance(value, str) and value.strip() for value in (self.password, self.language) + ) + + @dataclass(repr=False) class DeliveryContext: # Keep raw JSON values until is_complete validates the required strings. @@ -21,11 +42,13 @@ class DeliveryContext: locale: object = None extension: object = None risk_context: object = None + text_to_voice: TextToVoice | None = None @classmethod def from_payload(cls, payload: object) -> "DeliveryContext | None": if not isinstance(payload, dict): return None + voice = payload.get("voice") return cls( nonce=payload.get("nonce"), phone_number=payload.get("phoneNumber"), @@ -33,6 +56,7 @@ def from_payload(cls, payload: object) -> "DeliveryContext | None": locale=payload.get("locale"), extension=payload.get("extension"), risk_context=payload.get("riskContext"), + text_to_voice=TextToVoice.from_payload(voice.get("text2voice")) if isinstance(voice, dict) else None, ) @property @@ -51,6 +75,7 @@ class DispatchRequest: message_id: str correlation_id: str | None locale: str | None + text_to_voice: TextToVoice | None = None @dataclass(repr=False) diff --git a/python/src/provider_tokens.py b/python/src/provider_tokens.py new file mode 100644 index 0000000..dedab0d --- /dev/null +++ b/python/src/provider_tokens.py @@ -0,0 +1,198 @@ +import logging +import math +import time +from contextvars import ContextVar +from dataclasses import dataclass +from threading import Lock +from urllib.parse import urlsplit + +from azure.identity import ClientAssertionCredential, ClientSecretCredential, ManagedIdentityCredential +from requests.exceptions import Timeout as RequestsTimeout +from urllib3.exceptions import TimeoutError as UrllibTimeout + +TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default" + +# logging_enable=False controls HTTP tracing, not Identity's exception/account diagnostics. +# Filter only this synchronous acquisition's records, without muting concurrent invocations. +_acquiring_token = ContextVar("acquiring_provider_token", default=False) + + +class _SdkLogFilter(logging.Filter): + def filter(self, record): + return not _acquiring_token.get() + + +_sdk_log_filter = _SdkLogFilter() + + +def _filter_sdk_logs(): + for name, logger in tuple(logging.Logger.manager.loggerDict.items()): + if isinstance(logger, logging.Logger) and (name.startswith("azure.") or name == "msal" or name.startswith("msal.")): + logger.addFilter(_sdk_log_filter) + + +def valid_bearer_token(value): + return isinstance(value, str) and bool(value) and all(32 < ord(character) < 127 for character in value) + + +def _valid_vault_url(value): + if not isinstance(value, str) or not value.lower().startswith("https://") or any( + character.isspace() or ord(character) < 32 or character in "\\#" for character in value + ): + return False + try: + url = urlsplit(value) + return (url.scheme == "https" and bool(url.hostname) and url.username is None and url.password is None + and not url.fragment and not url.netloc.endswith(":") and (url.port is None or url.port > 0)) + except ValueError: + return False + + +@dataclass(frozen=True, repr=False) +class ProviderTokenConfig: + provider_id: str + endpoint: str + tenant_id: str + client_id: str + scope: str + client_secret_name: str + mi_client_id: str + vault_url: str + vault_client_id: str + timeout_seconds: float + + @classmethod + def read(cls, config, provider_id, timeout_seconds): + env = config.env + tenant = env.get("EPP_PROVIDER_TENANT_ID") or "" + client = env.get("EPP_PROVIDER_CLIENT_ID") or "" + scope = env.get("EPP_PROVIDER_SCOPE") or "" + secret_name = env.get("EPP_PROVIDER_CLIENT_SECRET_NAME") or "" + mi_client = env.get("EPP_PROVIDER_MI_CLIENT_ID") or "" + vault_url = env.get("KEY_VAULT_URL") or "" + vault_client = env.get("AZURE_CLIENT_ID") or "" + # Settings checks, not JWT verification; the SDK acquires/caches tokens, and the provider verifies them. + required_settings = [tenant, client, scope, secret_name or mi_client] + if vault_client: + required_settings.append(vault_client) + valid_settings = all(valid_bearer_token(value) for value in required_settings) + + valid_authority = valid_settings and ( + tenant.lower() not in ("common", "organizations", "consumers", "adfs") + and all(character.isascii() and (character.isalnum() or character in "-.") for character in tenant) + and scope.endswith("/.default") and scope != "/.default" + ) + + valid_credentials = ( + bool(secret_name) != bool(mi_client) + and "EPP_PROVIDER_CLIENT_SECRET" not in env + and "EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE" not in env + and (not secret_name or _valid_vault_url(vault_url)) + ) + + if not (valid_settings and valid_authority and valid_credentials): + raise ValueError("invalid provider token configuration") + return cls(provider_id, config.provider_endpoint, tenant, client, scope, secret_name, + mi_client, vault_url, vault_client, timeout_seconds) + + +class ProviderTokenError(Exception): + def __init__(self, timed_out=False): + super().__init__("provider token unavailable") + self.timed_out = timed_out + + +def _is_timeout(error): + pending, seen = [error], set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + if isinstance(current, (TimeoutError, RequestsTimeout, UrllibTimeout)): + return True + pending.extend(nested for nested in ( + current.__cause__, current.__context__, getattr(current, "inner_exception", None), *current.args + ) if isinstance(nested, Exception)) + return False + + +def _validated_token(result): + token = getattr(result, "token", None) + expires_on = getattr(result, "expires_on", None) + if (not valid_bearer_token(token) or type(expires_on) not in (int, float) + or not math.isfinite(expires_on) or expires_on <= time.time() + 60): + raise ProviderTokenError() + return token + + +class ProviderTokenAcquirer: + """One active credential (and its SDK token cache), never a custom token cache.""" + + def __init__(self): + self._lock = Lock() + self._key = None + self._credential = None + self._managed_identity = None + + def _clear(self): + for credential in (self._credential, self._managed_identity): + if credential is not None: + try: + credential.close() + except Exception: + pass + self._key = self._credential = self._managed_identity = None + + def acquire(self, config: ProviderTokenConfig, secrets): + _filter_sdk_logs() + logging_context = _acquiring_token.set(True) + try: + # Serialize use/replacement so a configuration change cannot close an in-flight credential. + if not self._lock.acquire(timeout=config.timeout_seconds): + raise ProviderTokenError(timed_out=True) + try: + secret = secrets.resolve(config.client_secret_name) if config.client_secret_name else "" + if config.client_secret_name and (not isinstance(secret, str) or not secret.strip()): + raise ProviderTokenError() + key = (config, secret) # Includes the resolved secret so rotation replaces the SDK cache. + if key != self._key: + self._clear() + # Synchronous connect/read limits, not a total wall-clock deadline. Provider HTTP is separate. + options = dict(connection_timeout=config.timeout_seconds, read_timeout=config.timeout_seconds, + retry_total=0, logging_enable=False) + try: + if config.client_secret_name: + self._credential = ClientSecretCredential( + tenant_id=config.tenant_id, client_id=config.client_id, client_secret=secret, + authority="https://login.microsoftonline.com", **options, + ) + else: + self._managed_identity = ManagedIdentityCredential(client_id=config.mi_client_id, **options) + identity = self._managed_identity + + def assertion(): + return _validated_token(identity.get_token(TOKEN_EXCHANGE_SCOPE)) + + self._credential = ClientAssertionCredential( + tenant_id=config.tenant_id, client_id=config.client_id, func=assertion, + authority="https://login.microsoftonline.com", **options, + ) + except Exception: + self._clear() + raise + self._key = key + _filter_sdk_logs() # Include any loggers initialized by the credential constructors. + return _validated_token(self._credential.get_token(config.scope)) + finally: + self._lock.release() + except ProviderTokenError: + raise + except Exception as error: + raise ProviderTokenError(timed_out=_is_timeout(error)) from None + finally: + _acquiring_token.reset(logging_context) + + +# Reused even when callers construct an engine for each invocation. No I/O during construction. +default_token_acquirer = ProviderTokenAcquirer() \ No newline at end of file diff --git a/python/src/providers/__init__.py b/python/src/providers/__init__.py index e69de29..d7e0fca 100644 --- a/python/src/providers/__init__.py +++ b/python/src/providers/__init__.py @@ -0,0 +1,17 @@ +import importlib + + +_PROVIDERS = { + "infobip": ("src.providers.infobip", "InfobipProvider"), + "sinch": ("src.providers.sinch", "SinchProvider"), + "soprano": ("src.providers.soprano", "SopranoProvider"), + "telesign": ("src.providers.telesign", "TelesignProvider"), +} + + +def load_provider(provider_id): + target = _PROVIDERS.get(provider_id) + if target is None: + return None + module_name, class_name = target + return getattr(importlib.import_module(module_name), class_name)() diff --git a/python/src/providers/infobip.py b/python/src/providers/infobip.py index eb8de22..ab4b265 100644 --- a/python/src/providers/infobip.py +++ b/python/src/providers/infobip.py @@ -43,9 +43,14 @@ def build_request(self, channel, endpoint, dispatch, credential, env): def parse_response(self, http_status, ok, json_body): messages = json_body.get("messages") if isinstance(json_body, dict) else None - first_message = messages[0] if messages else {} - status = first_message.get("status") or {} - status_name = (status.get("groupName") or status.get("name") or "").upper() or None + first_message = messages[0] if isinstance(messages, list) and messages else {} + first_message = first_message if isinstance(first_message, dict) else {} + status = first_message.get("status") + status = status if isinstance(status, dict) else {} + value = status.get("groupName") + if value is None: + value = status.get("name") + status_name = value.upper() if isinstance(value, str) and value.strip() else "UNKNOWN" return ParsedResponse( success=ok, provider_http_status=http_status, diff --git a/python/src/providers/sinch.py b/python/src/providers/sinch.py index cb6e3fa..9453731 100644 --- a/python/src/providers/sinch.py +++ b/python/src/providers/sinch.py @@ -38,13 +38,16 @@ def build_request(self, channel, endpoint, dispatch, credential, env): return {"url": f"{endpoint}/xms/v1/{service_plan_id}/batches", "method": "POST", "headers": headers, "body": json.dumps(body)} def parse_response(self, http_status, ok, json_body): - identifier = None - if isinstance(json_body, dict): - identifier = json_body.get("id") or json_body.get("callId") + payload = json_body if isinstance(json_body, dict) else {} + links = payload.get("_links") + self_link = links.get("self") if isinstance(links, dict) else None + link = self_link.get("href") if isinstance(self_link, dict) else self_link + identifier = next((value for value in (payload.get("id"), payload.get("callId"), link) + if isinstance(value, str) and value.strip()), None) return ParsedResponse( success=ok, provider_http_status=http_status, - provider_message_id=str(identifier) if identifier is not None else None, - provider_status_name="Dispatched" if ok else None, + provider_message_id=identifier, + provider_status_name="Dispatched" if ok and identifier else "UNKNOWN", provider_status_description=json_body.get("text") if isinstance(json_body, dict) else None, ) diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index 93e088b..db02184 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -1,13 +1,16 @@ import json -from ..models import ParsedResponse +from ..models import ParsedResponse, TextToVoice +from ..provider_tokens import valid_bearer_token class SopranoProvider: manifest = { "id": "soprano", + "requires_text_to_voice": True, "auth": { "mode": "apiKey", + "supports_oauth": True, "key_vault_secret_name": "soprano-api-key", "identity_key_vault_secret_name": "soprano-api-id", }, @@ -21,18 +24,36 @@ class SopranoProvider: def build_request(self, channel, endpoint, dispatch, credential, env): message_type = "voice" if channel == "voice" else "sms" headers = { - "X-MEMS-API-ID": credential.get("identity") or "", - "X-MEMS-API-Key": credential.get("secret") or "", "Content-Type": "application/json", "Accept": "application/json", } + mode = credential.get("mode", "") + mode = mode.lower() if isinstance(mode, str) else "" + token = credential.get("token") + if mode == "apikey": + headers["X-MEMS-API-ID"] = credential.get("identity") or "" + headers["X-MEMS-API-Key"] = credential.get("secret") or "" + elif mode != "oauth2" or not valid_bearer_token(token): + raise ValueError("unsupported provider credential") + if valid_bearer_token(token): + headers["Authorization"] = "Bearer " + token body = { - "text": dispatch.message, "destination": str(dispatch.destination).lstrip("+"), "messageTypes": [message_type], "correlationId": dispatch.correlation_id or dispatch.message_id, "shutterMode": False, } + if channel == "voice": + voice = dispatch.text_to_voice + if not isinstance(voice, TextToVoice) or not voice.is_complete: + raise ValueError("incomplete voice context") + body["voice"] = {"text2voice": { + "beforePasswordText": voice.before_password_text, + "password": voice.password, + "language": voice.language, + }} + else: + body["text"] = dispatch.message return {"url": f"{endpoint.rstrip('/')}/messages/omnimsg", "method": "POST", "headers": headers, "body": json.dumps(body)} def parse_response(self, http_status, ok, json_body): diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py index 58bd95a..de3888f 100644 --- a/python/src/providers/telesign.py +++ b/python/src/providers/telesign.py @@ -48,12 +48,13 @@ def build_request(self, channel, endpoint, dispatch, credential, env): return {"url": f"{endpoint}{path}", "method": "POST", "headers": headers, "body": urllib.parse.urlencode(form)} def parse_response(self, http_status, ok, json_body): - status = json_body.get("status") or {} if isinstance(json_body, dict) else {} + status = json_body.get("status") if isinstance(json_body, dict) else None + status = status if isinstance(status, dict) else {} code = status.get("code") return ParsedResponse( success=ok, provider_http_status=http_status, provider_message_id=json_body.get("reference_id") if isinstance(json_body, dict) else None, - provider_status_code=str(code) if code is not None else None, + provider_status_code=str(code) if type(code) in (int, float) or (isinstance(code, str) and code.strip()) else "UNKNOWN", provider_status_description=status.get("description"), ) diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index 9bf87f7..824391d 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -6,13 +6,14 @@ import pytest from src.dispatch import DispatchRequest, ProviderRegistry, context_to_dispatch, parse_envelope -from src.models import DeliveryContext, Envelope, ParsedResponse +from src.models import DeliveryContext, Envelope, ParsedResponse, TextToVoice from src.providers.infobip import InfobipProvider from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider from src.providers.telesign import TelesignProvider -MESSAGE = " Use 918273; then 1234.\nDo not rewrite + or café. " +MESSAGE = " Use 918273; then 1 2 3 4.\nDo not rewrite + or café. " +FIXTURES = json.loads((Path(__file__).resolve().parents[2] / "tests/fixtures/contract.json").read_text(encoding="utf-8")) def _dispatch(channel="sms"): @@ -21,8 +22,11 @@ def _dispatch(channel="sms"): @pytest.mark.parametrize("channel", ["sms", "voice"]) def test_soprano_exact_sms_and_voice_contract(channel): + dispatch = _dispatch(channel) + dispatch.text_to_voice = TextToVoice.from_payload(FIXTURES["textToVoice"]) + assert SopranoProvider.manifest["requires_text_to_voice"] is True request = ProviderRegistry([SopranoProvider()]).get("SOPRANO").build_request( - channel, "https://qa4.example/cgpapi///", _dispatch(channel), + channel, "https://qa4.example/cgpapi///", dispatch, {"mode": "apiKey", "identity": "test-id", "secret": "test-key"}, {}, ) @@ -32,9 +36,19 @@ def test_soprano_exact_sms_and_voice_contract(channel): "Content-Type": "application/json", "Accept": "application/json", } assert json.loads(request["body"]) == { - "text": MESSAGE, "destination": "15551234567", "messageTypes": [channel], + **({"voice": {"text2voice": FIXTURES["textToVoice"]}} if channel == "voice" else {"text": MESSAGE}), + "destination": "15551234567", "messageTypes": [channel], "correlationId": "correlation-id", "shutterMode": False, } + # Successful JWT-only and dual-header SMS/voice requests are covered by the handler matrix. + assert SopranoProvider().build_request( + channel, "https://qa4.example/cgpapi///", dispatch, + {"mode": "apiKey", "token": "invalid\r\n", "identity": "test-id", "secret": "test-key"}, {}, + ) == request + for credential in ({"mode": "unknown", "secret": "key"}, {"mode": "oauth2"}, + *({"mode": "oauth2", "token": token} for token in ("", " ", "two tokens", "token\r\n"))): + with pytest.raises(ValueError, match="unsupported provider credential"): + SopranoProvider().build_request(channel, "https://provider.example", _dispatch(channel), credential, {}) response = SopranoProvider().parse_response(201, True, {"id": 123, "status": "ENROUTE"}) assert response == ParsedResponse(True, 201, provider_message_id="123", provider_status_name="ENROUTE") assert "ENROUTE" not in repr(response) @@ -111,6 +125,22 @@ def test_request_models_preserve_content_and_accept_valid_routing_and_ttl(): assert MESSAGE not in repr(context) + repr(dispatch) assert "encrypted_delivery_context" not in repr(envelope) assert DeliveryContext.from_payload(None) is None + assert context.text_to_voice is None and dispatch.text_to_voice is None + payload = {"nonce": "nonce", "phoneNumber": "+15551234567", "message": MESSAGE} + voice_context = DeliveryContext.from_payload({**payload, "voice": {"text2voice": FIXTURES["textToVoice"]}}) + voice = voice_context.text_to_voice + assert isinstance(voice, TextToVoice) and voice.is_complete + assert (voice.before_password_text, voice.password, voice.language) == tuple(FIXTURES["textToVoice"].values()) + assert context_to_dispatch(voice_context, envelope, "message-id").text_to_voice is voice + for value in FIXTURES["textToVoice"].values(): + assert value not in repr(voice) + for changes in FIXTURES["incompleteVoiceContexts"]: + invalid = DeliveryContext.from_payload({**payload, **changes}) + assert invalid.is_complete + assert invalid.text_to_voice is None or not invalid.text_to_voice.is_complete + assert TextToVoice.from_payload({**FIXTURES["textToVoice"], "password": 12345}).password is None + for prefix in ("", " " * 1601): + assert TextToVoice(prefix, "012345", "en-GB").is_complete def test_envelope_parser_rejects_invalid_inputs_with_the_contract_reason(): diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index 6d352f8..e750231 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -1,3 +1,4 @@ +import json from unittest.mock import Mock import pytest @@ -6,6 +7,7 @@ import src.dispatch as dispatch_module from src.config import AppConfig, read_config from src.dispatch import DispatchEngine, DispatchRequest, ProviderRegistry +from src.models import TextToVoice from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider @@ -23,13 +25,37 @@ def engine(monkeypatch): def test_missing_key_or_identity_never_sends(engine): - for missing in ("soprano-api-key", "soprano-api-id"): - engine.secrets.resolve.side_effect = lambda name: None if name == missing else "test-key" + engine.token_acquirer = Mock() + for flag in ("false", "true"): + engine.env.update(EPP_PROVIDER_JWT_ENABLED=flag, EPP_PROVIDER_TENANT_ID="tenant-id", + EPP_PROVIDER_CLIENT_ID="client-id", EPP_PROVIDER_SCOPE="api://provider/.default", + EPP_PROVIDER_MI_CLIENT_ID="mi-id") + for missing in ("soprano-api-key", "soprano-api-id"): + for value in ((None, "", " ", "key\r\n", "key\x01", 123) if flag == "true" else (None, "")): + engine.secrets.resolve.side_effect = lambda name: value if name == missing else "test-key" + status, body = engine.dispatch(_request(), "r") + assert status == 502 and body["reason"] == "provider credential unavailable" + engine.secrets.resolve.side_effect = RuntimeError("PRIVATE-VAULT-DETAILS") status, body = engine.dispatch(_request(), "r") assert status == 502 and body["reason"] == "provider credential unavailable" + engine.token_acquirer.acquire.assert_not_called() dispatch_module.requests.request.assert_not_called() +def test_other_channels_and_providers_ignore_incomplete_voice_metadata(engine): + # The real-JWE handler test covers Soprano voice rejection before any credential work. + dispatch_module.requests.request.return_value = Mock(status_code=201, json=Mock(return_value={"status": "ENROUTE", "callId": "call-id"})) + for provider, channel in (("soprano", "sms"), ("sinch", "voice")): + engine.env["EPP_PROVIDER_NAME"] = provider + request = _request(channel) + request.text_to_voice = TextToVoice(None, None, None) + assert engine.dispatch(request, "r")[0] == 200 + wire = json.loads(dispatch_module.requests.request.call_args.kwargs["data"]) + assert "voice" not in wire + assert (wire["text"] if channel == "sms" else wire["ttsCallout"]["text"]) == request.message + assert dispatch_module.requests.request.call_count == 2 + + 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 07a088d..655183d 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -13,6 +13,7 @@ import function_app import src.dispatch as dispatch_module +from src.provider_tokens import ProviderTokenError _KEY = jwk.JWK.generate(kty="RSA", size=2048) _PRIVATE_PEM = _KEY.export_to_pem(private_key=True, password=None).decode() @@ -35,6 +36,7 @@ def _isolate(monkeypatch): engine = dispatch_module.DispatchEngine( function_app._registry, Mock(resolve=Mock(return_value="test-key")), {"EPP_PROVIDER_NAME": "soprano", "EPP_PROVIDER_ENDPOINT": "https://qa4.example/cgpapi"}, + token_acquirer=Mock(), ) monkeypatch.setattr(function_app, "_engine", engine) monkeypatch.setattr(dispatch_module.requests, "request", Mock()) @@ -126,14 +128,18 @@ def test_jwe_authenticates_original_protected_header_bytes(): dispatch_module.requests.request.assert_not_called() -def test_evaluation_decrypts_without_provider_configuration_or_work(monkeypatch, caplog): +@pytest.mark.parametrize("auth_mode,jwt_enabled", [("apiKey", "true"), ("oauth2", "true"), ("oauth2", "invalid")]) +def test_evaluation_decrypts_without_provider_configuration_or_work(monkeypatch, caplog, auth_mode, jwt_enabled): caplog.set_level(logging.INFO) monkeypatch.setenv("EPP_ENCRYPTION_KEY_ID", "configured-key-id") monkeypatch.delenv("EPP_PROVIDER_NAME") + monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", auth_mode) + monkeypatch.setenv("EPP_PROVIDER_JWT_ENABLED", jwt_enabled) function_app._engine.env.clear() + function_app._engine.env.update(EPP_PROVIDER_AUTH_MODE=auth_mode, EPP_PROVIDER_JWT_ENABLED=jwt_enabled) lookup = Mock() monkeypatch.setattr(function_app._registry, "get", lookup) - response = _HANDLER(_request(_envelope(mode="Evaluation", provider="untrusted-body-provider"))) + response = _HANDLER(_request(_envelope(channel=2, mode="Evaluation", provider="untrusted-body-provider"))) assert response.status_code == 200 assert json.loads(response.get_body()) == { "nonce": _NONCE, "correlationId": _CORRELATION, "providerStatus": "accepted", @@ -144,12 +150,21 @@ def test_evaluation_decrypts_without_provider_configuration_or_work(monkeypatch, assert all(value not in caplog.text for value in ("configured-key-id", "test-kid", "untrusted-body-provider")) lookup.assert_not_called() function_app._engine.secrets.resolve.assert_not_called() + function_app._engine.token_acquirer.acquire.assert_not_called() dispatch_module.requests.request.assert_not_called() -def test_live_acceptance_waits_and_preserves_wire_data_but_not_plaintext_logs(monkeypatch, caplog): +@pytest.mark.parametrize("auth_mode,jwt_enabled", [("apiKey", "false"), ("apiKey", "true"), ("oauth2", "true")]) +@pytest.mark.parametrize("channel", ["sms", "voice"]) +def test_live_acceptance_waits_and_preserves_wire_data_but_not_plaintext_logs(monkeypatch, caplog, auth_mode, jwt_enabled, channel): caplog.set_level(logging.INFO) monkeypatch.setenv("EPP_LOG_PLAINTEXT", "true") # Must not bypass privacy. + function_app._engine.env.update({ + "EPP_PROVIDER_AUTH_MODE": auth_mode, "EPP_PROVIDER_JWT_ENABLED": jwt_enabled, "EPP_PROVIDER_TENANT_ID": "tenant-id", + "EPP_PROVIDER_CLIENT_ID": "client-id", "EPP_PROVIDER_SCOPE": "api://provider/.default", + "EPP_PROVIDER_MI_CLIENT_ID": "mi-id", + }) + function_app._engine.token_acquirer.acquire.return_value = "provider-token" entered, release = Event(), Event() upstream = Mock(status_code=202, json=Mock(return_value={"status": "ENROUTE"})) @@ -161,7 +176,8 @@ def wait_for_acceptance(*args, **kwargs): send = Mock(side_effect=wait_for_acceptance) monkeypatch.setattr(dispatch_module.requests, "request", send) with ThreadPoolExecutor(max_workers=1) as executor: - request = _request(_envelope(channel=2), {"x-ms-client-request-id": "wire-message"}) + compact = _encrypt(context={**_CONTEXT, "voice": {"text2voice": _FIXTURES["textToVoice"]}}) + request = _request(_envelope(channel=channel, encryptedDeliveryContext=compact), {"x-ms-client-request-id": "wire-message"}) pending = executor.submit(_HANDLER, request) try: assert entered.wait(5), "handler did not reach provider" @@ -176,26 +192,141 @@ def wait_for_acceptance(*args, **kwargs): send.assert_called_once() upstream.close.assert_called_once() wire = json.loads(send.call_args.kwargs["data"]) - assert wire["text"] == _MESSAGE and wire["messageTypes"] == ["voice"] and wire["correlationId"] == _CORRELATION + content = {"voice": {"text2voice": _FIXTURES["textToVoice"]}} if channel == "voice" else {"text": _MESSAGE} + assert wire == {"destination": _PHONE.lstrip("+"), "messageTypes": [channel], "correlationId": _CORRELATION, + "shutterMode": False, **content} + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if jwt_enabled == "true": + function_app._engine.token_acquirer.acquire.assert_called_once() + headers["Authorization"] = "Bearer provider-token" + else: + function_app._engine.token_acquirer.acquire.assert_not_called() + if auth_mode == "oauth2": + function_app._engine.secrets.resolve.assert_not_called() + else: + headers.update({"X-MEMS-API-ID": "test-key", "X-MEMS-API-Key": "test-key"}) + assert function_app._engine.secrets.resolve.call_count == 2 + assert send.call_args.kwargs["headers"] == headers summary = json.loads(caplog.records[-1].getMessage().removeprefix("[EPP] result ")) assert len(caplog.records) == 1 assert set(summary) == {"requestId", "correlationId", "httpStatus", "elapsedMs", "evaluation"} assert summary["correlationId"] == hashlib.sha256(_CORRELATION.encode()).hexdigest()[:16] - for private in (_NONCE, _PHONE, _MESSAGE, "123456", _CORRELATION, "wire-message", "test-key"): + for private in (_NONCE, _PHONE, _MESSAGE, "123456", _CORRELATION, "wire-message", "test-key", "provider-token"): assert private not in caplog.text + for private in _FIXTURES["textToVoice"].values(): + assert private not in caplog.text + response.get_body().decode() + + +def test_incomplete_encrypted_voice_fails_even_with_valid_outer_voice(caplog): + caplog.set_level(logging.INFO) + function_app._engine.env["EPP_PROVIDER_JWT_ENABLED"] = "true" + for mode in ("apiKey", "oauth2"): + function_app._engine.env["EPP_PROVIDER_AUTH_MODE"] = mode + for changes in _FIXTURES["incompleteVoiceContexts"]: + compact = _encrypt(context={**_CONTEXT, **changes}) + response = _HANDLER(_request(_envelope(channel=2, encryptedDeliveryContext=compact, + voice={"text2voice": _FIXTURES["textToVoice"]}))) + result = json.loads(response.get_body()) + assert response.status_code == 400 + assert result == {"error": "provider_delivery_failed", "correlationId": _CORRELATION, + "requestId": result["requestId"]} + for private in (_NONCE, _PHONE, _MESSAGE, *_FIXTURES["textToVoice"].values()): + assert private not in caplog.text + function_app._engine.secrets.resolve.assert_not_called() + function_app._engine.token_acquirer.acquire.assert_not_called() + dispatch_module.requests.request.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) - monkeypatch.setattr(dispatch_module.requests, "request", send) - response = _HANDLER(_request(_envelope())) - body = json.loads(response.get_body()) - assert response.status_code == 429 and body["error"] == "provider_delivery_failed" - assert "nonce" not in body - send.assert_called_once() - assert send.call_args.kwargs["allow_redirects"] is False - upstream.close.assert_called_once() + function_app._engine.env.update({ + "EPP_PROVIDER_JWT_ENABLED": "true", "EPP_PROVIDER_TENANT_ID": "tenant-id", + "EPP_PROVIDER_CLIENT_ID": "client-id", "EPP_PROVIDER_SCOPE": "api://provider/.default", + "EPP_PROVIDER_MI_CLIENT_ID": "mi-id", + }) + function_app._engine.token_acquirer.acquire.return_value = "provider-token" + for provider_status, expected in ((429, 429), (401, 401)): + upstream = Mock(status_code=provider_status, json=Mock(return_value={"status": "ENROUTE"})) + send = Mock(return_value=upstream) + monkeypatch.setattr(dispatch_module.requests, "request", send) + response = _HANDLER(_request(_envelope())) + body = json.loads(response.get_body()) + assert response.status_code == expected and body["error"] == "provider_delivery_failed" + assert "nonce" not in body + send.assert_called_once() # No API-key-only resend after the optional bearer is rejected. + assert send.call_args.kwargs["headers"]["Authorization"] == "Bearer provider-token" + assert send.call_args.kwargs["headers"]["X-MEMS-API-Key"] == "test-key" + assert send.call_args.kwargs["allow_redirects"] is False + upstream.close.assert_called_once() + function_app._engine.env["EPP_PROVIDER_AUTH_MODE"] = "oauth2" + send.reset_mock() + for timed_out, status in ((False, 502), (True, 504)): + function_app._engine.token_acquirer.acquire.side_effect = ProviderTokenError(timed_out=timed_out) + response = _HANDLER(_request(_envelope())) + body = json.loads(response.get_body()) + assert response.status_code == status and body["error"] == "provider_delivery_failed" + assert "nonce" not in body + send.assert_not_called() + + +def test_all_providers_require_acceptance_evidence_and_preserve_failed_http(monkeypatch, caplog): + caplog.set_level(logging.INFO) + cases = [ + ("infobip", [ + {"messages": [{"status": {"groupName": "PENDING"}}]}, + {"messages": [{"status": {"name": "ACCEPTED"}}]}, + {"messages": [{"status": {"groupName": None, "name": "DELIVERED"}}]}, + ], [ + {"messages": {"0": {"status": {"groupName": "PENDING"}}}}, {"messages": [None]}, + {"messages": [{"status": []}]}, {"messages": [{"status": {"name": 123}}]}, + *({"messages": [{"status": {"groupName": value, "name": "PENDING"}}]} for value in (False, [], "", " ")), + ]), + ("telesign", [{"status": {"code": code}} for code in (290, "290", 100, "100")], [ + {"status": {}}, {"status": []}, + *({"status": {"code": code}} for code in (True, [290], {}, "")), + ]), + ("sinch", [{"id": "batch-id"}, {"callId": "call-id"}, + {"_links": {"self": "/batches/batch-id"}}, {"_links": {"self": {"href": "/calls/call-id"}}}], [ + {"id": " "}, {"callId": 123}, {"id": {"href": "/not-an-id"}}, + {"_links": {"self": {"href": 123}}}, {"_links": {"self": " "}}, {"status": "Dispatched"}, + ]), + ("soprano", [{"status": "ENROUTE"}, [{"state": "ACCEPTED"}]], + [{"status": False, "state": "ACCEPTED"}, {"status": {}, "state": "ACCEPTED"}]), + ] + request = _request(_envelope()) + for provider, accepted, malformed in cases: + monkeypatch.setenv("EPP_PROVIDER_NAME", provider) + function_app._engine.env["EPP_PROVIDER_NAME"] = provider + adapter = function_app._registry.get(provider) + rejected = [{}, None, [], *malformed, *([] if provider == "soprano" else [[accepted[0]]])] + for payload in rejected: + parsed = adapter.parse_response(200, True, payload) + assert parsed.success is True # Transport success is not acceptance. + if provider != "soprano": + assert (parsed.provider_status_name or parsed.provider_status_code) == "UNKNOWN" + assert dispatch_module.resolve_outcome(adapter.manifest, parsed) == "Fail", (provider, payload) + responses = [ + *((200, raw, 502) for raw in ("PRIVATE-UPSTREAM", "", "{", *(json.dumps(p) for p in rejected))), + (200, json.dumps(accepted[0])[:-1] + ',"invalid":NaN}', 502), + *((201, json.dumps(payload), 200) for payload in accepted), + *((status, raw, 401 if status == 403 else 502 if status == 500 else status) + for status in (401, 403, 429, 500) + for raw in (json.dumps(accepted[0]), "PRIVATE-UPSTREAM")), + ] + for status, raw, expected in responses: + upstream = Mock(status_code=status, json=Mock(side_effect=lambda **options: json.loads(raw, **options))) + send = dispatch_module.requests.request + send.reset_mock() + send.return_value = upstream + response = _HANDLER(request) + body = json.loads(response.get_body()) + assert response.status_code == expected, (provider, status, raw) + if expected == 200: + assert body["nonce"] == _NONCE + else: + assert body["error"] == "provider_delivery_failed" and "nonce" not in body + send.assert_called_once() + upstream.close.assert_called_once() + assert "PRIVATE-UPSTREAM" not in response.get_body().decode() + caplog.text def test_unexpected_handler_error_is_generic_and_does_not_send(monkeypatch, caplog): diff --git a/python/tests/test_provider_loading.py b/python/tests/test_provider_loading.py new file mode 100644 index 0000000..3d4e78c --- /dev/null +++ b/python/tests/test_provider_loading.py @@ -0,0 +1,158 @@ +import os +import shutil +import subprocess +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import src.dispatch as dispatch_module +from src.dispatch import ProviderRegistry + + +def test_explicit_registry_keeps_injected_adapters_without_loading(monkeypatch): + load = Mock(side_effect=AssertionError("default loader must not run")) + monkeypatch.setattr(dispatch_module, "load_provider", load) + adapter = Mock(manifest={"id": "Custom"}) + registry = ProviderRegistry([adapter]) + assert registry.get("CUSTOM") is adapter + assert registry.get(None) is None + assert registry.get("soprano") is None + assert ProviderRegistry([]).get("soprano") is None + load.assert_not_called() + + +# A fresh interpreter cannot reuse adapters imported by the other test modules. +_PROBE = """ +import io +import json +import logging +import os +import socket +import sys +from pathlib import Path +from unittest.mock import Mock, call + +import azure.functions as func +import requests +from jwcrypto import jwe, jwk + +socket.socket.connect = Mock(side_effect=AssertionError("network forbidden")) +socket.getaddrinfo = Mock(side_effect=AssertionError("network forbidden")) +logs = io.StringIO() +logging.basicConfig(stream=logs, level=logging.INFO) +selected, available = sys.argv[1], sys.argv[2] == "True" +os.environ.update(EPP_PROVIDER_NAME=selected.upper(), EPP_PROVIDER_ENDPOINT="https://provider.example") +upstream = Mock(status_code=202, json=Mock(return_value={ + "status": "ENROUTE" if selected == "soprano" else {"code": 290}, + "messages": [{"status": {"groupName": "PENDING"}}], "id": "test-message", +})) +requests.request = Mock(return_value=upstream) +from src.secrets import SecretResolver +SecretResolver.resolve = Mock(return_value="test-key") + +import function_app +import src.dispatch as dispatch_module +from src import providers + +assert Path(function_app.__file__).resolve() == Path.cwd() / "function_app.py" +assert Path(dispatch_module.__file__).resolve() == Path.cwd() / "src" / "dispatch.py" +assert not any(name.startswith("src.providers.") for name in sys.modules) +functions = function_app.app.get_functions() +assert len(functions) == 1 and functions[0].get_function_name() == "send_otp" +handler = functions[0].get_user_function() +lookup = Mock(wraps=function_app._registry.get) +function_app._registry.get = lookup +imports = Mock(wraps=providers.importlib.import_module) +providers.importlib.import_module = imports +function_app._engine.token_acquirer = Mock() + +key = jwk.JWK.generate(kty="RSA", size=2048) +function_app._key_provider = Mock(return_value=key.export_to_pem(private_key=True, password=None).decode()) +context = {"nonce": "test-nonce", "phoneNumber": "+15551234567", "message": "Your code is 123456"} +token = jwe.JWE(json.dumps(context).encode(), protected={"alg": "RSA-OAEP-256", "enc": "A256GCM"}) +token.add_recipient(key) +envelope = {"type": "microsoft.mfa.otpDeliver.v1", "channel": 1, "mode": 1, + "encryptedDeliveryContext": token.serialize(compact=True)} + +def invoke(payload): + raw = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + return handler(func.HttpRequest(method="POST", url="/api/SendOtp", headers={}, params={}, body=raw)) + +for invalid in (b"{", {**envelope, "channel": "invalid"}): + assert invoke(invalid).status_code == 400 +response = invoke({**envelope, "mode": 2, "channel": 2}) +assert response.status_code == 200 and json.loads(response.get_body())["nonce"] == context["nonce"] +lookup.assert_not_called() +imports.assert_not_called() +SecretResolver.resolve.assert_not_called() +requests.request.assert_not_called() + +for unknown in ("", "unknown", "../../../", "src.providers.soprano"): + os.environ["EPP_PROVIDER_NAME"] = unknown + response = invoke(envelope) + assert response.status_code == 400 + assert json.loads(response.get_body())["error"] == "provider_delivery_failed" +imports.assert_not_called() +SecretResolver.resolve.assert_not_called() +requests.request.assert_not_called() + +os.environ["EPP_PROVIDER_NAME"] = selected.upper() +response = invoke(envelope) +result = json.loads(response.get_body()) +if available: + assert response.status_code == 200 and result["nonce"] == context["nonce"] + requests.request.assert_called_once() + upstream.close.assert_called_once() + assert SecretResolver.resolve.call_count == (2 if selected in ("soprano", "telesign") else 1) + imports.assert_called_once_with("src.providers." + selected) + assert {name for name in sys.modules if name.startswith("src.providers.")} == {"src.providers." + selected} +else: + assert response.status_code == 502 and result["error"] == "provider_delivery_failed" + assert "nonce" not in result + request = dispatch_module.DispatchRequest(context["phoneNumber"], context["message"], "sms", "message", "correlation", "en-US") + status, body = function_app._engine.dispatch(request, "request") + assert status == 502 and body["reason"] == "provider unavailable" and body["outcome"] == "Fail" + assert imports.call_args_list == [call("src.providers." + selected)] * 2 + SecretResolver.resolve.assert_not_called() + requests.request.assert_not_called() + for private in ("PRIVATE-PROVIDER-ERROR", "PRIVATE_PROVIDER_ERROR", "private_dependency"): + assert private not in json.dumps(body) + response.get_body().decode() + logs.getvalue() +function_app._engine.token_acquirer.acquire.assert_not_called() +socket.socket.connect.assert_not_called() +socket.getaddrinfo.assert_not_called() +assert not any(value in logs.getvalue() for value in context.values()) +print("isolated provider loading verified") +""" + + +@pytest.mark.parametrize("layout", [ + "soprano", "infobip", "telesign", "sinch", "absent", + "missing-dependency", "broken-import", "invalid-source", +]) +def test_isolated_app_indexes_evaluates_and_loads_only_selected_provider(tmp_path, layout): + root = Path(__file__).resolve().parents[1] + provider_ids = ("soprano", "infobip", "telesign", "sinch") + available = layout in provider_ids + selected = layout if available else "soprano" + shutil.copy2(root / "function_app.py", tmp_path / "function_app.py") + omitted = [name + ".py" for name in provider_ids if name != layout] + shutil.copytree(root / "src", tmp_path / "src", ignore=shutil.ignore_patterns("__pycache__", *omitted)) + broken_source = { + "missing-dependency": "from .private_dependency import value\n", + "broken-import": "raise RuntimeError('PRIVATE-PROVIDER-ERROR')\n", + "invalid-source": "def PRIVATE_PROVIDER_ERROR(\n", + }.get(layout) + if broken_source: + (tmp_path / "src/providers/soprano.py").write_text(broken_source, encoding="utf-8") + for name in provider_ids: + if name != selected or layout == "absent": + assert not (tmp_path / "src/providers" / (name + ".py")).exists() + # Only OS essentials reach the child: no local settings, credential environment or repo import path. + env = {name: os.environ[name] for name in ("SYSTEMROOT", "WINDIR", "PATH", "TEMP", "TMP") if name in os.environ} + env.update(PYTHONPATH=str(tmp_path), PYTHONNOUSERSITE="1", PYTHONDONTWRITEBYTECODE="1") + result = subprocess.run([sys.executable, "-c", _PROBE, selected, str(available)], + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip() == "isolated provider loading verified" \ No newline at end of file diff --git a/python/tests/test_provider_tokens.py b/python/tests/test_provider_tokens.py new file mode 100644 index 0000000..eeed57a --- /dev/null +++ b/python/tests/test_provider_tokens.py @@ -0,0 +1,278 @@ +import logging +from types import SimpleNamespace +from unittest.mock import Mock, call + +import pytest +from azure.core.exceptions import ServiceRequestError + +import src.dispatch as dispatch_module +import src.provider_tokens as tokens +from src.config import read_config +from src.dispatch import DispatchEngine, DispatchRequest, ProviderRegistry +from src.providers.infobip import InfobipProvider +from src.providers.sinch import SinchProvider +from src.providers.soprano import SopranoProvider +from src.providers.telesign import TelesignProvider + + +def _token(value="provider-token", expires_on=4600): + return SimpleNamespace(token=value, expires_on=expires_on) + + +@pytest.fixture +def oauth(monkeypatch): + monkeypatch.setattr(tokens.time, "time", lambda: 1000) + credentials = [] + + def make_credential(**kwargs): + def get_token(scope): + if "func" in kwargs: + assert kwargs["func"]() == "exchange-token" + return _token("exchange-token" if scope == tokens.TOKEN_EXCHANGE_SCOPE else "provider-token") + + credential = Mock(get_token=Mock(side_effect=get_token)) + credentials.append(credential) + return credential + + constructors = [Mock(side_effect=make_credential) for _ in range(3)] + for name, constructor in zip(("ClientSecretCredential", "ManagedIdentityCredential", "ClientAssertionCredential"), constructors): + monkeypatch.setattr(tokens, name, constructor) + send = Mock(return_value=Mock(status_code=202, json=Mock(return_value={"status": "ENROUTE"}))) + monkeypatch.setattr(dispatch_module.requests, "request", send) + env = { + "EPP_PROVIDER_NAME": "soprano", "EPP_PROVIDER_AUTH_MODE": "OAUTH2", + "EPP_PROVIDER_JWT_ENABLED": "true", + "EPP_PROVIDER_ENDPOINT": "https://provider.example/cgpapi", + "EPP_PROVIDER_TENANT_ID": "tenant-id", "EPP_PROVIDER_CLIENT_ID": "client-id", + "EPP_PROVIDER_SCOPE": "api://provider/.default", "EPP_PROVIDER_CLIENT_SECRET_NAME": "provider-client-secret", + "KEY_VAULT_URL": "https://vault.example", "AZURE_CLIENT_ID": "vault-identity", + } + secrets = Mock(resolve=Mock(return_value="resolved-client-secret")) + registry = ProviderRegistry([SopranoProvider(), InfobipProvider(), SinchProvider(), TelesignProvider()]) + engine = DispatchEngine(registry, secrets, env, token_acquirer=tokens.ProviderTokenAcquirer()) + request = DispatchRequest("+15551234567", "Code: 1 2 3 4; keep 5678.", "sms", "message", "correlation", None) + return SimpleNamespace(engine=engine, request=request, env=env, secrets=secrets, send=send, + constructors=constructors, credentials=credentials) + + +def test_api_key_defaults_and_disabled_gate_skip_all_token_work(oauth, monkeypatch): + oauth.env.pop("EPP_PROVIDER_AUTH_MODE") + oauth.env.pop("EPP_PROVIDER_JWT_ENABLED") + oauth.env.update(EPP_PROVIDER_CLIENT_SECRET="forbidden", EPP_PROVIDER_TENANT_ID="") + read, acquire = Mock(), Mock() + monkeypatch.setattr(tokens.ProviderTokenConfig, "read", read) + oauth.engine.token_acquirer.acquire = acquire + for flag in (None, " FaLsE "): + if flag is not None: + oauth.env.update(EPP_PROVIDER_AUTH_MODE=" ApiKey ", EPP_PROVIDER_JWT_ENABLED=flag) + assert read_config(oauth.env).provider_jwt_enabled is False + assert oauth.engine.dispatch(oauth.request, "r")[0] == 200 + read.assert_not_called() + acquire.assert_not_called() + for constructor in oauth.constructors: + constructor.assert_not_called() + + +def test_invalid_auth_modes_and_provider_gates_precede_all_io(oauth): + invalid = [ + {"EPP_PROVIDER_AUTH_MODE": ""}, + {"EPP_PROVIDER_AUTH_MODE": "unsupported", "EPP_PROVIDER_JWT_ENABLED": "false"}, + {"EPP_PROVIDER_AUTH_MODE": "apiKey", "EPP_PROVIDER_JWT_ENABLED": "on"}, + {"EPP_PROVIDER_JWT_ENABLED": "false"}, + *({"EPP_PROVIDER_JWT_ENABLED": flag} for flag in ("", " ", "1", "0", "yes", "on", "false true")), + *({"EPP_PROVIDER_NAME": name, "EPP_PROVIDER_AUTH_MODE": mode} + for name in ("infobip", "sinch", "telesign") for mode in ("apiKey", "oauth2")), + ] + for changes in invalid: + oauth.engine.env = {**oauth.env, **changes} + status, body = oauth.engine.dispatch(oauth.request, "r") + assert status == 502 and body["outcome"] == "Fail", changes + oauth.secrets.resolve.assert_not_called() + oauth.send.assert_not_called() + for constructor in oauth.constructors: + constructor.assert_not_called() + + +def test_client_secret_sdk_reuse_rotation_and_configuration_invalidation(oauth): + oauth.env.update(EPP_PROVIDER_AUTH_MODE=" OAuth2 ", EPP_PROVIDER_JWT_ENABLED=" TrUe ") + secret_constructor, mi_constructor, assertion_constructor = oauth.constructors + for _ in range(2): + engine = DispatchEngine(oauth.engine.registry, oauth.secrets, oauth.env, oauth.engine.token_acquirer) + assert engine.dispatch(oauth.request, "r")[0] == 200 + options = dict(connection_timeout=1.5, read_timeout=1.5, retry_total=0, logging_enable=False) + secret_constructor.assert_called_once_with( + tenant_id="tenant-id", client_id="client-id", client_secret="resolved-client-secret", + authority="https://login.microsoftonline.com", **options, + ) + assert oauth.credentials[0].get_token.call_args_list == [call("api://provider/.default")] * 2 + assert oauth.secrets.resolve.call_args_list == [call("provider-client-secret")] * 2 + assert not oauth.engine.token_acquirer._lock.locked() + assert not tokens._acquiring_token.get() + config = tokens.ProviderTokenConfig.read(read_config(oauth.env), "soprano", 1.5) + for private in (*oauth.env.values(), "resolved-client-secret", "provider-token"): + assert private not in repr(config) + repr(oauth.engine.token_acquirer) + + oauth.secrets.resolve.return_value = "rotated-client-secret" + assert oauth.engine.dispatch(oauth.request, "r")[0] == 200 + oauth.credentials[0].close.assert_called_once() + assert secret_constructor.call_args.kwargs["client_secret"] == "rotated-client-secret" + # Every identity/source/endpoint setting and transport timeout participates in the one-entry key. + for name, value in ( + ("EPP_PROVIDER_ENDPOINT", "https://other-provider.example"), + ("EPP_PROVIDER_TENANT_ID", "other-tenant"), ("EPP_PROVIDER_CLIENT_ID", "other-client"), + ("EPP_PROVIDER_SCOPE", "resource/.default"), ("EPP_PROVIDER_CLIENT_SECRET_NAME", "other-secret"), + ("KEY_VAULT_URL", "https://other-vault.example"), ("AZURE_CLIENT_ID", "other-vault-identity"), + ("EPP_PROVIDER_TIMEOUT_MS", "99999"), + ): + previous = oauth.credentials[-1] + count = secret_constructor.call_count + oauth.env[name] = value + assert oauth.engine.dispatch(oauth.request, "r")[0] == 200 + previous.close.assert_called_once() + assert secret_constructor.call_count == count + 1 + assert secret_constructor.call_args.kwargs["connection_timeout"] == 2.5 + assert secret_constructor.call_args.kwargs["read_timeout"] == 2.5 + oauth.credentials[-1].get_token.assert_called_once_with("resource/.default") + mi_constructor.assert_not_called() + assertion_constructor.assert_not_called() + assert DispatchEngine(None, None).token_acquirer is DispatchEngine(None, None).token_acquirer + + +def test_managed_identity_assertion_exchange_uses_two_scopes_and_reuses_pair(oauth): + oauth.env.pop("EPP_PROVIDER_CLIENT_SECRET_NAME") + oauth.env.update(EPP_PROVIDER_MI_CLIENT_ID="mi-id", EPP_PROVIDER_TIMEOUT_MS="invalid") + secret_constructor, mi_constructor, assertion_constructor = oauth.constructors + for _ in range(2): + assert oauth.engine.dispatch(oauth.request, "r")[0] == 200 + options = dict(connection_timeout=1.5, read_timeout=1.5, retry_total=0, logging_enable=False) + mi_constructor.assert_called_once_with(client_id="mi-id", **options) + assertion_constructor.assert_called_once_with( + tenant_id="tenant-id", client_id="client-id", func=assertion_constructor.call_args.kwargs["func"], + authority="https://login.microsoftonline.com", **options, + ) + identity, assertion = oauth.credentials + assert identity.get_token.call_args_list == [call("api://AzureADTokenExchange/.default")] * 2 + assert assertion.get_token.call_args_list == [call("api://provider/.default")] * 2 + assert oauth.send.call_args.kwargs["headers"]["Authorization"] == "Bearer provider-token" + oauth.env["EPP_PROVIDER_MI_CLIENT_ID"] = "replacement-mi" + assert oauth.engine.dispatch(oauth.request, "r")[0] == 200 + identity.close.assert_called_once() + assertion.close.assert_called_once() + assert mi_constructor.call_count == assertion_constructor.call_count == 2 + secret_constructor.assert_not_called() + oauth.secrets.resolve.assert_not_called() + oauth.env["EPP_PROVIDER_AUTH_MODE"] = " ApiKey " + assert oauth.engine.dispatch(oauth.request, "r")[0] == 200 + assert oauth.secrets.resolve.call_args_list == [call("soprano-api-key"), call("soprano-api-id")] + assert oauth.send.call_args.kwargs["headers"]["Authorization"] == "Bearer provider-token" + assert oauth.send.call_args.kwargs["headers"]["X-MEMS-API-Key"] == "resolved-client-secret" + assert mi_constructor.call_count == assertion_constructor.call_count == 2 + + +@pytest.mark.parametrize("auth_mode", ["apiKey", "oauth2"]) +def test_token_config_and_sdk_failures_follow_required_or_optional_policy(oauth, caplog, auth_mode): + oauth.env["EPP_PROVIDER_AUTH_MODE"] = auth_mode + optional = auth_mode == "apiKey" + key_calls = [call("soprano-api-key"), call("soprano-api-id")] if optional else [] + + def check_failure(expected): + oauth.send.reset_mock() + oauth.secrets.resolve.reset_mock() + status, body = oauth.engine.dispatch(oauth.request, "r") + assert status == (200 if optional else expected) + assert "PRIVATE" not in str(body) + caplog.text + if optional: + assert oauth.secrets.resolve.call_args_list[:2] == key_calls + oauth.send.assert_called_once() + assert oauth.send.call_args.kwargs["headers"] == { + "Content-Type": "application/json", "Accept": "application/json", + "X-MEMS-API-ID": "resolved-client-secret", "X-MEMS-API-Key": "resolved-client-secret", + } + else: + oauth.send.assert_not_called() + + # One representative per field/danger class, not a field-by-character cross product. + invalid = [ + *({name: ""} for name in ("EPP_PROVIDER_TENANT_ID", "EPP_PROVIDER_CLIENT_ID", + "EPP_PROVIDER_SCOPE", "EPP_PROVIDER_CLIENT_SECRET_NAME")), + *({"EPP_PROVIDER_TENANT_ID": tenant} for tenant in ("COMMON", "Organizations", "consumers", "AdFs", "tenant/common")), + {"EPP_PROVIDER_CLIENT_ID": " client"}, {"EPP_PROVIDER_SCOPE": "res ource/.default"}, + {"EPP_PROVIDER_CLIENT_SECRET_NAME": "secret\x7f"}, {"AZURE_CLIENT_ID": "vault\u00e9"}, + {"EPP_PROVIDER_MI_CLIENT_ID": "mi\x01", "EPP_PROVIDER_CLIENT_SECRET_NAME": ""}, + {"EPP_PROVIDER_MI_CLIENT_ID": "mi-id"}, # Ambiguous secret + federation sources. + *({"KEY_VAULT_URL": url} for url in ("", "http://vault.example", "https://", "https://user@vault.example", + "https://vault.example/#fragment", "https://vault.example:0")), + *({"EPP_PROVIDER_SCOPE": scope} for scope in ("/.default", "api://provider", "api://p/.default\r\n")), + {"EPP_PROVIDER_CLIENT_SECRET": ""}, {"EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE": ""}, + ] + for changes in invalid: + oauth.engine.env = {**oauth.env, **changes} + check_failure(502) + assert oauth.secrets.resolve.call_args_list == key_calls, changes + oauth.engine.env = oauth.env + for constructor in oauth.constructors: + constructor.assert_not_called() + + sdk_logger = logging.getLogger("azure.identity._internal.get_token_mixin") + # Simulate an in-flight cached credential: a waiter must neither close it nor release its lock. + acquirer = oauth.engine.token_acquirer + acquirer._credential = Mock() + oauth.env["EPP_PROVIDER_TIMEOUT_MS"] = "1" + acquirer._lock.acquire() + try: + check_failure(504) + assert acquirer._lock.locked() and not tokens._acquiring_token.get() + acquirer._credential.close.assert_not_called() + assert oauth.secrets.resolve.call_args_list == key_calls + for constructor in oauth.constructors: + constructor.assert_not_called() + finally: + acquirer._lock.release() + oauth.env.pop("EPP_PROVIDER_TIMEOUT_MS") + credential = Mock() + oauth.constructors[0].side_effect = None + oauth.constructors[0].return_value = credential + results = (None, *(_token(value) for value in ("", " ", "two tokens", "token\r\n", "token\x01", "token\x7f", "t\u00f6ken")), + *(_token(expires_on=value) for value in (None, 999, 1060, float("nan"), float("inf"))), + RuntimeError("PRIVATE-SDK-DETAILS"), ServiceRequestError("PRIVATE-SDK-DETAILS", error=TimeoutError())) + for result in results: + def get_token(scope): + sdk_logger.warning("PRIVATE-SDK-DETAILS") + if isinstance(result, Exception): + raise result + return result + + credential.get_token = Mock(side_effect=get_token) + check_failure(504 if isinstance(result, ServiceRequestError) else 502) + credential.get_token.assert_called_once_with("api://provider/.default") + assert not oauth.engine.token_acquirer._lock.locked() and not tokens._acquiring_token.get() + oauth.constructors[0].side_effect = RuntimeError("PRIVATE-CONSTRUCTOR-DETAILS") + oauth.engine.token_acquirer = tokens.ProviderTokenAcquirer() + check_failure(502) + for secret in (None, "", " ", RuntimeError("PRIVATE-VAULT-DETAILS")): + # Resolve required API keys first, then return/raise the client-secret result. + oauth.secrets.resolve.side_effect = ["resolved-client-secret"] * len(key_calls) + [secret] + oauth.constructors[0].reset_mock() + check_failure(502) + oauth.constructors[0].assert_not_called() + oauth.constructors[1].assert_not_called() + oauth.constructors[2].assert_not_called() + assert "PRIVATE" not in caplog.text + sdk_logger.warning("outside-acquisition") + assert "outside-acquisition" in caplog.text + + +def test_invalid_federation_assertion_stops_before_provider_token_or_delivery(oauth): + oauth.env.pop("EPP_PROVIDER_CLIENT_SECRET_NAME") + oauth.env["EPP_PROVIDER_MI_CLIENT_ID"] = "mi-id" + identity = Mock(get_token=Mock(return_value=_token("exchange-token", expires_on=1000))) + oauth.constructors[1].side_effect = None + oauth.constructors[1].return_value = identity + assert oauth.engine.dispatch(oauth.request, "r")[0] == 502 + identity.get_token.assert_called_once_with(tokens.TOKEN_EXCHANGE_SCOPE) + oauth.engine.token_acquirer = tokens.ProviderTokenAcquirer() + oauth.constructors[2].side_effect = RuntimeError("PRIVATE-CONSTRUCTOR-DETAILS") + assert oauth.engine.dispatch(oauth.request, "r")[0] == 502 + identity.close.assert_called_once() + oauth.send.assert_not_called() + oauth.secrets.resolve.assert_not_called() \ No newline at end of file diff --git a/tests/fixtures/contract.json b/tests/fixtures/contract.json index 4d6c5cd..f923a25 100644 --- a/tests/fixtures/contract.json +++ b/tests/fixtures/contract.json @@ -27,6 +27,27 @@ { "name": "encryption before TTL", "overrides": { "encryptedDeliveryContext": "", "ttlSeconds": "bad" }, "reason": "encryptedDeliveryContext is required" }, { "name": "channel before mode and TTL", "overrides": { "channel": false, "mode": null, "ttlSeconds": "bad" }, "reason": "unsupported channel" } ], + "textToVoice": { "beforePasswordText": " Your code is \n", "password": "012345", "language": "en-GB" }, + "incompleteVoiceContexts": [ + {}, + { "voice": null }, + { "voice": [] }, + { "voice": "voice" }, + { "voice": 1 }, + { "voice": {} }, + { "voice": { "text2voice": null } }, + { "voice": { "text2voice": [] } }, + { "voice": { "text2voice": "012345" } }, + { "voice": { "text2voice": { "beforePasswordText": "Your code is", "password": 12345, "language": "en-GB" } } }, + { "voice": { "text2voice": { "beforePasswordText": "Your code is", "password": "012345" } } }, + { "voice": { "text2voice": { "password": "012345", "language": "en-GB" } } }, + { "voice": { "text2voice": { "beforePasswordText": 123, "password": "012345", "language": "en-GB" } } }, + { "voice": { "text2voice": { "beforePasswordText": "Your code is", "password": null, "language": "en-GB" } } }, + { "voice": { "text2voice": { "beforePasswordText": "Your code is", "password": "\u2003\t", "language": "en-GB" } } }, + { "voice": { "text2voice": { "beforePasswordText": "Your code is", "password": "012345", "language": 2057 } } }, + { "voice": { "text2voice": { "beforePasswordText": "Your code is", "password": "012345", "language": "\u2003\n" } } }, + { "textToVoice": { "beforePasswordText": "Your code is", "password": "012345", "language": "en-GB" } } + ], "incompleteContexts": [ { "nonce": "" }, { "nonce": null },