From da8492456001c41c679dc214d0ee778426d86b02 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 11 Sep 2026 10:48:25 -0700 Subject: [PATCH] Update Telesign to current Endpoint --- README.md | 61 +++- docs/CONTRACT.md | 32 +- docs/ONBOARDING.md | 329 ++++++++---------- docs/local.settings.sample.json | 1 - dotnet/README.md | 2 +- dotnet/Src/Providers/TelesignProvider.cs | 51 ++- dotnet/tests/ContractTests.cs | 61 +++- javascript/README.md | 2 +- javascript/src/functions/dispatch.js | 19 +- .../src/functions/providers/telesign.js | 53 ++- javascript/test/dispatch.test.js | 43 ++- javascript/test/sendotp.test.js | 47 +++ python/README.md | 2 +- python/src/providers/telesign.py | 66 ++-- python/tests/test_contract.py | 51 ++- 15 files changed, 485 insertions(+), 335 deletions(-) diff --git a/README.md b/README.md index 3a5cfbc..8fd92f5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# External Phone Provider — Azure Function Sample +# External Phone Provider: Azure Function Sample A provider-agnostic **OTP-delivery Azure Function** sample, implemented across multiple languages. Each language folder is a self-contained implementation of the **same design and the same -[contract](docs/CONTRACT.md)** — one engine, drop-in provider adapters, env-provisioned config, and +[contract](docs/CONTRACT.md)**: one engine, drop-in provider adapters, env-provisioned config, and secrets in Key Vault. ## Implementations @@ -14,7 +14,7 @@ secrets in Key Vault. | Python (v2 model) | Available | [python/](python/) | All implementations conform to the **language-agnostic contract** in -[docs/CONTRACT.md](docs/CONTRACT.md) — identical HTTP API, provider-adapter shape, config/env var +[docs/CONTRACT.md](docs/CONTRACT.md): identical HTTP API, provider-adapter shape, config/env var names, Key Vault secret names, and behaviors (fail-closed, managed identity, privacy). Pick any folder and follow its README. @@ -22,7 +22,7 @@ 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). -New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — setup, config, running, securing, +New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** for setup, config, running, securing, and deploying, step by step. ## The design in one line @@ -59,7 +59,7 @@ how code accesses configuration, not the environment-variable names. | Variable | When needed | Value | |---|---|---| | `AzureWebJobsStorage` | Functions host storage | Local sample: `UseDevelopmentStorage=true` with Azurite running. Configure Azure host storage separately for the selected plan. | -| `FUNCTIONS_WORKER_RUNTIME` | Functions host | `node`, `python`, or `dotnet-isolated`—exactly one value matching the chosen implementation. | +| `FUNCTIONS_WORKER_RUNTIME` | Functions host | `node`, `python`, or `dotnet-isolated`. Choose the value matching your implementation. | | `EPP_DECRYPTION_KEY_PEM` | Every request | Local test PEM or base64 PEM. In Azure, use a Key Vault reference resolving to the private-key secret. | | `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. | @@ -78,6 +78,8 @@ how code accesses configuration, not the environment-variable names. 3. Store provider API keys and any required identity secrets in Key Vault using the **exact names in the adapter manifest**. Grant that app/slot's managed identity *Key Vault Secrets User* on those secrets. An API key in a local environment variable is not a supported replacement for the resolver. + See the [provider credential naming table](docs/ONBOARDING.md#provider-credential-names) and + [local use of existing cloud secrets](docs/ONBOARDING.md#local-settings-and-cloud-secrets). Evaluation requests do not need provider variables or provider secrets. They still need the decryption key. The default credential resolvers use `ManagedIdentityCredential`, **not** the developer's CLI @@ -93,6 +95,49 @@ Configure inbound issuer/audience/caller trust in **Easy Auth**, not these appli Incoming `tenantId`, `channel`, `mode` and `ttlSeconds` are request data. No outbound OAuth settings are supported by this main-based implementation. +## Telesign EPP + +The `telesign` adapter uses `POST https://verify.telesign.com/integration/msft/cyot` +for both SMS and Voice. Set `EPP_PROVIDER_NAME=telesign` and +`EPP_PROVIDER_ENDPOINT=https://verify.telesign.com` (the base URL, without the route). +This replaces the legacy `/v1/messaging` and `/v1/voice` integrations in all three languages. + +Basic authentication uses `base64(customer-id:api-key)`, with the existing Key Vault secrets +`telesign-customer-id` and `telesign-api-key`. Digest and Phase 2 token authentication are not +implemented. The incoming caller's Authorization header is never forwarded. + +The adapter builds the following JSON from the decrypted delivery context and envelope: + +```json +{ + "recipient": { "phone_number": "+1234567890" }, + "message": { "text": "Your verification code is 4821", "language": "en" }, + "channels": [{ "channel": "voice" }], + "correlation_id": "unique-string-123" +} +``` + +`phoneNumber` must match `^\+[1-9][0-9]{1,14}$`; the leading `+` is preserved. The complete +`message` is passed unchanged as `message.text`, including whitespace and OTP digit spacing. +Telesign performs text-to-speech for Voice; no separate speech object or OTP extraction is needed. +A nonblank string `locale` becomes `message.language`; otherwise language is omitted. The envelope +channel selects the single `sms` or `voice` entry. `correlation_id` uses a nonempty string request +correlation ID, falling back to the message ID for absent, empty, or non-string values. Reserved +`account_lifecycle_event` and `originating_ip` fields are +not sent; no client-IP inference or account-event default is applied. `TELESIGN_VOICE` and the +legacy sender/form fields no longer affect this adapter. + +Telesign's API supports `X-Shutter-Mode: true` for direct provider tests. The Function deliberately +omits that header on live sends and does not forward it from incoming requests. Use the existing +`mode: 2` evaluation path for Function tests without delivery: it skips provider HTTP and credential +lookup entirely, rather than invoking Telesign shutter mode. + +Responses normalize `reference_id` and `status.code`/`status.description` internally; provider +metadata is not logged or exposed in the public nonce response. Existing numeric success codes +are retained (SMS: 200, 203, 290-292; Voice: 100-103); the supplied EPP integration overview does not provide +a replacement status-code catalog. Missing, malformed, or unknown codes fail closed, as do +unsuccessful HTTP responses. Confirm these codes and account access with Telesign before production. + ## Security **Easy Auth (App Service Authentication) is the only caller-authentication gate, before the anonymous @@ -114,13 +159,13 @@ authentication; [separate deployed security checks](docs/ONBOARDING.md#4-package ## Docs -- **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — customer setup / run / secure / deploy guide. -- **[docs/CONTRACT.md](docs/CONTRACT.md)** — the language-agnostic contract every implementation follows. +- **[docs/ONBOARDING.md](docs/ONBOARDING.md)**: customer setup, security, deployment, and validation. +- **[docs/CONTRACT.md](docs/CONTRACT.md)**: the language-agnostic contract every implementation follows. ## Contributing a language or provider - **New provider** (in any language): add one adapter file exposing `manifest` + `buildRequest` + - `parseResponse` — no engine changes. See the language folder's README. + `parseResponse`; no engine changes. See the language folder's README. - **New language**: mirror the folder structure, implement the contract, add the same test scenarios, and wire it into [.github/workflows/ci.yml](.github/workflows/ci.yml). diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 01ab72d..b459c27 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,4 +1,4 @@ -# External Phone Provider Function — Language-Agnostic Contract +# External Phone Provider Function: Language-Agnostic Contract This defines the shared contract for [JavaScript](../javascript/), [Python](../python/) and [.NET](../dotnet/). See [production limitations](#production-limitations) before production use. @@ -34,7 +34,7 @@ Forwarded headers, including `x-ms-client-principal`, do not establish trust by replace the required Easy Auth gate. The handler does not use them to authenticate callers or forward the incoming `Authorization` header to the provider. Configure Easy Auth as described in section 5. -### EPP request body — `Envelope` (cleartext envelope) +### EPP request body: `Envelope` (cleartext envelope) | Field | Required | Notes | |-------|----------|-------| @@ -141,8 +141,8 @@ success-looking status. Explicit `Block`/`StepUp` outcomes remain non-success re | `Fail` | `401` | provider returned 401/403 (auth) | | `Fail` | `400` | other provider 4xx | | `Fail` | `502` | other provider error, or missing credential/endpoint | -| — | `504` | request to the provider timed out | -| — | `502` | network error to the provider (non-timeout) | +| N/A | `504` | request to the provider timed out | +| N/A | `502` | network error to the provider (non-timeout) | --- @@ -150,10 +150,10 @@ success-looking status. Explicit `Block`/`StepUp` outcomes remain non-success re 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 - - `responseMapping` — map of provider status → `Continue` | `Fail` | `Block` | `StepUp` (+ `default`) +- **`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 + - `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` @@ -174,8 +174,10 @@ 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. +the implementation reads adapter-specific options from app settings. Individual API contracts remain +in the adapters; the [onboarding credential naming table](ONBOARDING.md#provider-credential-names) +lists the exact manifest secret names for provisioning and authorized local tests. Keep that table +aligned with the manifests; never include secret values in documentation or the settings sample. --- @@ -230,10 +232,10 @@ subscription activation and changing tenant policy belong to provisioning, not t ## 5. Required behaviors -- **Fail-closed** — only `Continue` → `200 accepted`; unknown status → `Fail`. -- **Managed identity** — Key Vault access via managed identity only (user-assigned if `AZURE_CLIENT_ID` +- **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. -- **Privacy** — never log phone numbers, passcodes, nonce values, bearer tokens, API keys, JWE headers/payloads, +- **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 correlation ID's SHA256 hash, HTTP status, @@ -241,7 +243,7 @@ subscription activation and changing tenant policy belong to provisioning, not t echo remain unchanged. Hashes are pseudonymous, not anonymous; restrict log access and retention. A configured encryption-key-ID mismatch adds a fixed warning, never either key ID or the JWE header. Disable SDK, platform and proxy body tracing separately. -- **Platform authentication only** — enable Easy Auth with `requireAuthentication=true`, +- **Platform authentication only**: enable Easy Auth with `requireAuthentication=true`, `unauthenticatedClientAction=Return401` and `requireHttps=true`. Configure the trusted tenant issuer and `allowedAudiences` for the endpoint app, plus a **nonempty `allowedApplications`** list pinned to the authorized SAS caller application ID. No excluded path may bypass authentication for SendOtp. @@ -251,7 +253,7 @@ subscription activation and changing tenant policy belong to provisioning, not t or bypassed.** Core Tools supplies no Easy Auth: local execution must bind only to loopback, with no tunnels or public forwarding. Neither request data, JWE decryption, a fixed nonce nor forwarded principal headers authenticate the SAS caller. -- **Timeout boundaries** — platform authentication and Key Vault retrieval happen outside the outbound HTTP +- **Timeout boundaries**: platform authentication and Key Vault retrieval happen outside the outbound HTTP timer. Python uses connect/read inactivity timeouts, not a hard elapsed-time deadline. The cap therefore does not guarantee a 3.2-second end-to-end response, especially on cold starts. A timed-out POST may already have been accepted; avoid blind retries that duplicate messages. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index bbb14e1..8031707 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -1,187 +1,142 @@ -# Customer Onboarding - -A shared setup guide for the External Phone Provider OTP Function. Use one implementation: -[JavaScript](../javascript/README.md), [Python](../python/README.md) or [.NET](../dotnet/README.md). -[CONTRACT.md](CONTRACT.md) defines shared settings and behavior; the selected adapter and its manifest -define required credentials and options. No provider is preferred or selected by default. - -## 1. Select and configure an adapter - -Choose a registered adapter for the selected provider and an account supporting the required channels. -Set `EPP_PROVIDER_NAME` to its actual manifest id (`` is only a placeholder), and configure -its matching `EPP_PROVIDER_ENDPOINT` and required options. One provider is active per deployment; -request fields cannot change it. Purchasing or activating a subscription does not install an adapter. - -Store credentials under the Key Vault secret names declared by the selected adapter's manifest, not -in code or app settings. Grant the Function's managed identity *Key Vault Secrets User* access at the -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. - -### 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: - -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 - the Function identity read access there. An encryption-key reference does not configure this client. -2. Store the API key under the selected manifest's `keyVaultSecretName` (`key_vault_secret_name` in - Python). If the manifest also declares `identityKeyVaultSecretName` - (`identity_key_vault_secret_name`), store the matching API/customer ID as a separate secret. - `EPP_PROVIDER_ACCOUNT_NAME` is a sender/account option, **not** that credential ID or the API key. - Keep secret values out of parameters, console transcripts and checked-in settings. -3. Give `EPP_PROVIDER_ENDPOINT` the **base URL expected by the adapter**. Bundled adapters append the - channel-specific API path. Do not pass an already complete send URL unless an adapter explicitly - expects it. Use the same account/environment for the endpoint and its credential pair. -4. Supply any additional options read by the selected adapter. Registering a provider does not make - every account option or channel automatically available. - -The script already writes the correct `EPP_` names; no variable-prefix translation is required. - -| Setup value | Current application behavior | -|---|---| -| `EPP_PROVIDER_NAME` | Selects one registered adapter; no implicit default. | -| `EPP_PROVIDER_ENDPOINT` | Base URL, with the final send path built by the adapter. | -| `EPP_PROVIDER_TIMEOUT_MS` | Default 1500 ms; positive decimal values are capped at 2500 ms. Zero/invalid values use the default, not an infinite timeout. | -| `EPP_PROVIDER_RETRY_INTERVAL_MS` | Not consumed. Calls are not automatically retried; writing this setting does not enable retries. | -| `EPP_PROVIDER_ACCOUNT_NAME` | Adapter-specific sender/account option, separate from credential secrets. | -| `EPP_DECRYPTION_KEY_PEM` | PEM or base64 PEM, usually resolved from a Key Vault secret reference. | -| `EPP_ENCRYPTION_KEY_ID` | Advisory mismatch warning only; not overlapping-key selection. | -| `EPP_EXPECTED_AUDIENCE`, `EPP_EXPECTED_ISSUER`, `EPP_EXPECTED_CLIENT_ID`, `EPP_TENANT_ID` | The script may write these, but this platform-authenticated application does not read them. The script's separate Easy Auth configuration enforces caller trust. | - -**Do not use the script's `-NoEasyAuth` option with this application.** There is no application token -validator to take over. For the script's v1 registration, configure Easy Auth with the identifier URI -as audience, `https://sts.windows.net/{tenantId}/` as issuer, and the authorized SAS application in -`allowedApplications`. Use the v2 audience/issuer only when the registration actually issues v2 tokens. -No Entra application role check is performed. Azure RBAC grants to the Function's managed identity -for storage/Key Vault are separate from granting application permissions to the SAS caller. - -The script alone does not make this implementation conform to every Preview 1 requirement: - -- The guide requires accepting before provider delivery. This implementation still waits for provider - acceptance. A durable handoff, expiry and duplicate-handling design is needed before changing that - acknowledgement boundary; starting an unawaited task is not a reliable replacement. -- The guide requires selecting retained private keys by `kid`. This implementation has one configured - key. A versionless secret reference alone does not retain both keys during rotation. -- The guide requires voice digits to be spoken separately. This implementation preserves the supplied - message; verify the selected voice API's behavior rather than assuming unspaced digits are intelligible. - -The pasted script also needs its advertised 100-byte UTF-8 endpoint-URL check before deployment. -A public-only certificate cannot supply the private key it later exports. Treat failed infrastructure -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. - -## 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 -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. - -- Configure `EPP_DECRYPTION_KEY_PEM` through a Key Vault secret reference in Azure and give the caller - the matching public key. `EPP_ENCRYPTION_KEY_ID` is an optional advisory comparison after decryption, - not strict key pinning or multi-key lookup. - -**Do not enable Entra access-token encryption for the Easy Auth resource app.** Leave its app -registration's `tokenEncryptionKeyId` as `null`; if previously configured, clear that property without -deleting its certificates or changing signing keys. This integration expects a signed bearer JWT, -not an encrypted access token that requires a separate private-key decryption step before validation. -After changing the registration, request a fresh token rather than reusing a cached encrypted token. -The resource is the endpoint app configured in Easy Auth's `clientId`/audience, not necessarily the -application requesting the token. The application code does not configure `tokenEncryptionKeyId`. - -This is separate from the **required JWE encryption of `encryptedDeliveryContext`** in the request -body. Keep `EPP_DECRYPTION_KEY_PEM`; `EPP_ENCRYPTION_KEY_ID` only produces an advisory warning after -successful payload decryption and cannot cause a platform `401`. - -Configure caller trust in the Function App's **App Service Authentication (Easy Auth)** platform -settings, not application environment variables: - -- Enable the authentication platform and Microsoft Entra identity provider. Set - `globalValidation.requireAuthentication=true`, - `globalValidation.unauthenticatedClientAction=Return401` and `httpSettings.requireHttps=true`. -- Under `identityProviders.azureActiveDirectory.registration`, configure the endpoint app's client ID - and a tenant-specific `openIdIssuer` for the trusted SAS issuer tenant and token version. Do not use - `common` or `organizations`, or derive the issuer from request `tenantId` or unverified claims. -- Under `identityProviders.azureActiveDirectory.validation.allowedAudiences`, configure the exact - endpoint-app audience agreed during SAS onboarding. For v2 tokens this is the endpoint app client-ID - GUID; a v1 configuration may use its Application ID URI. The provisioning URI is not automatically - the v2 audience. This is the endpoint app, not the provider or caller application. -- Set `identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications` - to a **nonempty** allowlist containing the authorized SAS caller application ID supplied during - onboarding. Do not substitute the endpoint app ID, allow every tenant application, or leave this list empty. -- Do not exempt `/api/SendOtp` through `globalValidation.excludedPaths` or any alternate ingress route. - Verify these requirements on every serving app and slot, including after configuration changes or swaps. - -Easy Auth is the **only** caller-authentication gate before the `authLevel: anonymous` HTTP Function. -The handler does not validate bearer tokens or authenticate forwarded principal headers; there is no -backup application validation or function-key gate. **Do not expose the endpoint to the public internet -with Easy Auth disabled or bypassed.** JWE decryption does not authenticate SAS: anyone with the public -key can encrypt a request. A nonce echo, including a fixed nonce, is not authentication or replay protection. - -Incoming `mode`, `channel`, `ttlSeconds` and `tenantId` are request data, not customer deployment -settings or sources of identity trust. There is no application host-detection authentication guard. - -**Migration:** Older application authentication settings are no longer consumed. Deployments running -older code require a separate rollout; source edits do not update them. Configure and verify the -platform gate before publishing this code, then repeat the deployed security checks below. - -## 3. Validate without delivery - -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. -Platform trust configuration and the decryption key remain prerequisites; see the -[evaluation contract](CONTRACT.md#evaluation-generic-shutter). - -Core Tools does **not** provide Easy Auth. Local evaluation exercises the unauthenticated application -path only: bind the host exclusively to loopback, with no tunnels, public forwarding or shared-network -exposure. Use local test keys and synthetic request data. Sending an authorization header locally -does not enable authentication, and local success does not verify platform security. - -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. - -## 4. Package, deploy and verify - -Build and publish only the chosen language folder, retaining runtime dependencies or using a supported -remote build. Configure and verify Easy Auth before publishing; keep public ingress disabled until -the required platform gate is in place. Verify managed identity access, encryption and platform -authentication settings on the deployed app. Inspect the final package; do not publish the repository -root or reuse stale build output. - -The repository-root [.gitignore](../.gitignore) covers all runtimes and nested helper scripts. -Publishing has separate exclusions in [JavaScript](../javascript/.funcignore), -[Python](../python/.funcignore) and [.NET](../dotnet/.funcignore); local settings, private keys and tests -must stay out of the package. The [.NET project](../dotnet/dotnet.csproj) also excludes local settings -from publish output. Application logs are privacy-limited; disable platform/SDK body tracing separately. - -### Required deployed security checks - -Offline suites cover local application behavior, **not Easy Auth**. Separately test the deployed -endpoint with non-delivering evaluation requests and synthetic data: - -- Missing, malformed, expired or invalidly signed credentials receive `401` before handler execution. -- Wrong tenant issuer or endpoint audience is rejected; a valid token for an application outside the - SAS caller allowlist is denied before handler execution. Check the actual non-success status rather - than relying on the handler's response schema for platform errors. -- An authorized SAS caller with a valid encrypted evaluation request receives `200` and the matching - nonce, without provider lookup, provider Key Vault reads or provider HTTP. -- HTTPS is enforced, and no excluded path, alternate hostname, route or serving slot bypasses the - authentication gate for SendOtp. Verify the nonempty caller allowlist in the deployed configuration. - -Do not disable Easy Auth on a public endpoint to test failure behavior. Never record raw phone -numbers, messages, nonce values, bearer tokens, API keys, encrypted request bodies or provider response -bodies in test reports. Repeat these checks after deployment and any authentication or slot changes; -passing offline tests is not deployment security certification. - -## 5. Add an adapter - -Implement `manifest`, `buildRequest` and `parseResponse`, register the adapter in the chosen runtime, -then provision its credentials and options. Keep API-specific logic in the adapter, with fail-closed -response mapping; the shared delivery pipeline does not need provider-specific branches. +# EPP Onboarding + +Follow these five steps for the External Phone Provider (EPP) Function. Choose one language: +[JavaScript](../javascript/README.md), [Python](../python/README.md), or [.NET](../dotnet/README.md). +Use [CONTRACT.md](CONTRACT.md) for the full request contract and production limitations. + +1. **Purchase a provider offer from Security Store.** + + Open **Security Store > Provider offers**, purchase an offer, and activate the provider account. + Confirm EPP endpoint access and support for your required channels. Obtain the provider's API key + and matching customer/API ID, if required. + Purchasing an offer does not deploy this Function or install a missing provider adapter. + +2. **Run the app setup script.** + + + **The script, command, and prerequisites will be provided later.** Run it using the supplied + instructions, verify it succeeded, and retain the app/resource IDs and configuration outputs. + Do not assume it creates provider secrets or deploys the Function code. + +3. **Fill in app settings.** + + + Reuse your provider's existing cloud API key and matching ID. Store the raw values in Key Vault + under these exact names, shared across all three languages: + + | Provider | API credential secret | Matching identity secret | Authentication | + |---|---|---|---| + | `telesign` | `telesign-api-key` | `telesign-customer-id` | Basic: base64 of `customer-id:api-key` | + | `soprano` | `soprano-api-key` | `soprano-api-id` | `X-MEMS-API-Key` and `X-MEMS-API-ID` | + | `infobip` | `infobip-api-key` | None | `Authorization: App ` | + | `sinch` | `sinch-api-token` | None | `Authorization: Bearer ` | + + Secret names use lowercase and hyphens. Keep `sinch-api-token` unchanged. Store the API key and + matching ID separately, not a prebuilt Authorization header, base64 credential pair, or Entra token. + The adapter builds the headers; there is no provider OAuth/JWT acquisition flow. + + Enable the Function's managed identity and grant it **Key Vault Secrets User** access to the + required secrets. Confirm vault network access and that the keys match the provider environment. + Select one registered provider per deployment; there is no default. Unsupported providers need + an adapter first; see [adding a provider](../README.md#contributing-a-language-or-provider). + + + Start with [local.settings.sample.json](local.settings.sample.json) beside the chosen app's + `host.json`. Replace placeholders in `Values`; all values must be strings. For Telesign, use: + + ```json + { + "EPP_PROVIDER_NAME": "telesign", + "EPP_PROVIDER_ENDPOINT": "https://verify.telesign.com", + "KEY_VAULT_URL": "https://.vault.azure.net/" + } + ``` + + These are entries in `Values`, not a complete settings file. Use the adapter's **base URL**; + it adds the send path. App-setting names use uppercase and underscores. Provider API keys stay + in Key Vault, not `Values`: `TELESIGN_API_KEY`, `SOPRANO_API_KEY`, and `EPP_PROVIDER_API_KEY` + are not read by the production resolvers. `EPP_PROVIDER_ACCOUNT_NAME` is optional sender metadata, + not an API/customer ID. See the [settings catalog](CONTRACT.md#4-configuration-app-settings--env) + for adapter options and `AZURE_CLIENT_ID` when using a user-assigned managed identity. + + Set `FUNCTIONS_WORKER_RUNTIME` to `node`, `python`, or `dotnet-isolated`. Local + `UseDevelopmentStorage=true` requires Azurite; configure Azure host storage separately. + Use a local test private key for `EPP_DECRYPTION_KEY_PEM`; in Azure, use a Key Vault reference + and give the caller the matching public key. Core Tools does not resolve Key Vault references + locally. `EPP_ENCRYPTION_KEY_ID` is advisory only; this sample has one decryption key, not + multi-key rotation. The decryption key, provider credentials, and caller authentication are separate. + + For local work, keep the host **loopback-only**, without tunnels or public forwarding. Core Tools + has no Easy Auth, and `ManagedIdentityCredential` cannot use your CLI login. `AZURE_CLIENT_ID` + does not create a local identity. Use offline tests or evaluation mode by default. An authorized + live test can inject a private resolver that reads the same cloud secrets into memory using a + signed-in identity with secret-read permission. Do not add a production credential fallback, + print secrets, persist a secret cache, or change cloud settings merely to test locally. + + `KEY_VAULT_URL` selects the provider credential vault independently of the decryption-key reference. + Timeout defaults to 1500 ms and caps at 2500 ms; zero does not disable it. Retry settings are unused. + Configure caller trust in Easy Auth, not legacy `EPP_EXPECTED_*` or `EPP_TENANT_ID` settings. + +4. **Deploy the Functions.** + + + Configure **App Service Authentication (Easy Auth)** before exposing the endpoint. It is the + only caller-authentication gate; the Function handler is anonymous and has no backup validator. + Never use `-NoEasyAuth`, trust forwarded principal headers, or treat JWE/nonce proof as caller + authentication. Anyone with the public encryption key can create a JWE request. + + | Easy Auth setting | Required value | + |---|---| + | `globalValidation.requireAuthentication` | `true` | + | `globalValidation.unauthenticatedClientAction` | `Return401` | + | `httpSettings.requireHttps` | `true` | + | `identityProviders.azureActiveDirectory.registration.clientId` | Endpoint app's client ID | + | `identityProviders.azureActiveDirectory.registration.openIdIssuer` | Trusted tenant issuer matching the caller's token version; never `common` or `organizations` | + | `identityProviders.azureActiveDirectory.validation.allowedAudiences` | Exact endpoint-app audience agreed with SAS | + | `identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications` | Nonempty allowlist of authorized SAS caller application IDs, not the endpoint app ID | + | `globalValidation.excludedPaths` | No exemption for `/api/SendOtp` or an alternate ingress route | + + For v1 tokens, use the agreed Application ID URI audience and `https://sts.windows.net/{tenantId}/` + issuer; for v2, use the matching v2 issuer and agreed audience, normally the endpoint app client-ID + GUID. Do not derive trust from request `tenantId`. Key Vault RBAC for the Function identity is + separate from authorizing SAS callers; this code does not check an Entra application role. + + Leave the endpoint app registration's `tokenEncryptionKeyId` **null**: encrypted Entra access + tokens are not supported. If correcting an existing registration, do not delete certificates or + change signing keys; request a fresh token afterward. This does **not** disable the required + JWE encryption of `encryptedDeliveryContext` or remove `EPP_DECRYPTION_KEY_PEM`. + + Build and publish only the selected language folder with its runtime dependencies, not the + repository root or stale output. Inspect the package: exclude local settings, private keys, + credentials, tests, and diagnostic scripts using the runtime's `.funcignore` and publish rules. + Apply the settings from step 3 to the Function App's Azure environment; local settings are not + published automatically. Verify managed identity access and keep public ingress disabled + until authentication is configured. Source changes do not update an existing deployment. + +5. **Validate.** + + + Send an authorized `POST /api/SendOtp` with a valid encrypted envelope and `mode: 2` or + `mode: "evaluation"`. Expect `200` and the matching nonce, with no provider lookup, provider + secret reads, or outbound provider HTTP. See the [evaluation contract](CONTRACT.md#evaluation-generic-shutter). + + Before live testing, verify these cases on the deployed endpoint, not just in offline tests: + + | Check | Expected result | + |---|---| + | Missing, malformed, expired, or invalidly signed caller token | `401` before the handler | + | Wrong issuer/audience or caller outside the allowlist | Rejected before the handler | + | Authorized caller and valid evaluation envelope | `200` with matching nonce, no provider I/O | + | Alternate routes, hostnames, and serving slots | No authentication or HTTPS bypass | + + After those checks pass, confirm the destination and channel, use `mode: 1`, and submit once. + `200` with a matching nonce confirms provider acceptance, **not handset delivery**. Confirm receipt + and spoken digit clarity through the handset/provider reports. The message is forwarded unchanged. + Do not blindly retry a timeout: the provider may already have accepted the request. Review the + [production limitations](CONTRACT.md#production-limitations), including no durable handoff, + early acknowledgement, expiry enforcement, or multi-key rotation. + + Never record phone numbers, messages, nonce values, tokens, API keys, encrypted request bodies, + or raw provider responses in reports. Keep platform/SDK body tracing off. Repeat the deployed + checks after deployment, authentication changes, and slot swaps. Local evaluation and passing + unit tests do not certify platform authentication or live delivery. diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index 6071ed5..4cd6d3f 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -1,5 +1,4 @@ { - "_comment": "Local template: copy beside the chosen app's host.json and replace placeholders. Change node to python or dotnet-isolated for those runtimes. Start Azurite for UseDevelopmentStorage=true. Provider credentials stay in Key Vault. Local hosts have no Easy Auth; keep them on loopback. See README.md for Azure settings and optional values.", "IsEncrypted": false, "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", diff --git a/dotnet/README.md b/dotnet/README.md index 2a45212..c14c42c 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1,4 +1,4 @@ -# External Phone Provider Function — C# (.NET isolated worker) +# External Phone Provider Function: C# (.NET isolated worker) 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. diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs index 79978ad..6a486bf 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; namespace Epp.Otp.Providers; @@ -24,51 +25,41 @@ public sealed class TelesignProvider : IProviderAdapter public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { + if (channel is not ("sms" or "voice")) throw new InvalidOperationException("unsupported channel"); + if (dispatch.Destination is null || !Regex.IsMatch(dispatch.Destination, @"\A\+[1-9][0-9]{1,14}\z")) + throw new InvalidOperationException("invalid recipient"); var authorization = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.Identity}:{credential.Secret}")); - - var externalId = dispatch.CorrelationId ?? dispatch.MessageId; - var form = new Dictionary(); - string path; - if (channel == "voice") - { - path = "/v1/voice"; - form["phone_number"] = dispatch.Destination; - form["message"] = dispatch.Message ?? string.Empty; - form["message_type"] = "OTP"; - form["voice"] = env.Get("TELESIGN_VOICE") ?? "f-en-US"; - form["external_id"] = externalId; - } - else + var message = new Dictionary { ["text"] = dispatch.Message }; + if (!string.IsNullOrWhiteSpace(dispatch.Locale)) message["language"] = dispatch.Locale; + var body = new { - path = "/v1/messaging"; - form["phone_number"] = dispatch.Destination; - form["message"] = dispatch.Message ?? string.Empty; - form["sender_id"] = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? string.Empty; - form["message_type"] = "OTP"; - form["external_id"] = externalId; - form["is_primary"] = "true"; - } - + recipient = new { phone_number = dispatch.Destination }, + message, + channels = new[] { new { channel } }, + correlation_id = string.IsNullOrEmpty(dispatch.CorrelationId) ? dispatch.MessageId : dispatch.CorrelationId, + }; var headers = new Dictionary { ["Authorization"] = authorization, - ["Content-Type"] = "application/x-www-form-urlencoded", + ["Content-Type"] = "application/json", ["Accept"] = "application/json", }; - var encoded = string.Join("&", form.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); - return new ProviderHttpRequest($"{endpoint}{path}", "POST", headers, encoded); + return new ProviderHttpRequest($"{endpoint.TrimEnd('/')}/integration/msft/cyot", "POST", headers, JsonSerializer.Serialize(body)); } 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 == JsonValueKind.Number && code.TryGetInt32(out var numericCode)) + statusCode = numericCode.ToString(System.Globalization.CultureInfo.InvariantCulture); + 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..37962e3 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -67,12 +67,6 @@ public void OtherProvidersKeepTheirStaticAuthenticationAndProtocols() using var smsJson = JsonDocument.Parse(sms.Body); Assert.Equal(Request().Message, smsJson.RootElement.GetProperty("messages")[0].GetProperty("content").GetProperty("text").GetString()); - var form = new TelesignProvider().BuildRequest("sms", "https://provider.example", Request(), credential, env); - Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test-id:test-key")), form.Headers["Authorization"]); - Assert.Equal("application/x-www-form-urlencoded", form.Headers["Content-Type"]); - Assert.EndsWith("/v1/messaging", form.Url); - Assert.Contains("message=" + Uri.EscapeDataString(Request().Message!), form.Body); - var call = new SinchProvider().BuildRequest("voice", "https://provider.example", Request("voice"), credential, env); Assert.Equal("Bearer test-key", call.Headers["Authorization"]); // Static provider credential. Assert.Equal("https://calling.api.sinch.com/calling/v1/callouts", call.Url); @@ -80,6 +74,61 @@ public void OtherProvidersKeepTheirStaticAuthenticationAndProtocols() Assert.Equal(Request().Message, callJson.RootElement.GetProperty("ttsCallout").GetProperty("text").GetString()); } + [Theory] + [InlineData("sms", "en")] + [InlineData("voice", "en")] + [InlineData("sms", null)] + [InlineData("sms", "")] + [InlineData("sms", " ")] + public void TelesignUsesEppJsonContract(string channel, string? locale) + { + var dispatch = Request(channel) with { Locale = locale }; + var request = new TelesignProvider().BuildRequest(channel, "https://verify.telesign.com///", dispatch, + new ProviderCredential("apiKey", "test-key", "test-id"), new TestEnv()); + Assert.Equal("https://verify.telesign.com/integration/msft/cyot", request.Url); + Assert.Equal("POST", request.Method); + Assert.Equal(3, request.Headers.Count); + Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test-id:test-key")), request.Headers["Authorization"]); + Assert.Equal("application/json", request.Headers["Content-Type"]); + Assert.Equal("application/json", request.Headers["Accept"]); + var message = new Dictionary { ["text"] = dispatch.Message }; + if (locale == "en") message["language"] = locale; + var expected = new { recipient = new { phone_number = dispatch.Destination }, message, + channels = new[] { new { channel } }, correlation_id = dispatch.CorrelationId }; + Assert.Equal(JsonSerializer.Serialize(expected), request.Body); + } + + [Fact] + public void TelesignValidatesRecipientAndFallsBackToMessageId() + { + var adapter = new TelesignProvider(); + var credential = new ProviderCredential("apiKey", "key", "id"); + foreach (var destination in new[] { "15551234567", "+0123", "+1", "+1234567890123456", "+123\n", "+123\r", "+12 34" }) + Assert.Throws(() => adapter.BuildRequest("sms", "https://verify.telesign.com", + Request() with { Destination = destination }, credential, new TestEnv())); + Assert.Throws(() => adapter.BuildRequest("email", "https://verify.telesign.com", Request(), credential, new TestEnv())); + var request = adapter.BuildRequest("sms", "https://verify.telesign.com", Request() with { CorrelationId = null }, credential, new TestEnv()); + using var json = JsonDocument.Parse(request.Body); + Assert.Equal(Request().MessageId, json.RootElement.GetProperty("correlation_id").GetString()); + } + + [Theory] + [InlineData("{}", true, Outcome.Fail)] + [InlineData("{\"status\":[]}", true, Outcome.Fail)] + [InlineData("{\"status\":{\"code\":true}}", true, Outcome.Fail)] + [InlineData("{\"status\":{\"code\":\"290\"}}", true, Outcome.Fail)] + [InlineData("{\"status\":{\"code\":999}}", true, Outcome.Fail)] + [InlineData("{\"status\":{\"code\":290}}", false, Outcome.Fail)] + [InlineData("{\"status\":{\"code\":290}}", true, Outcome.Continue)] + [InlineData("{\"status\":{\"code\":100}}", true, Outcome.Continue)] + public void TelesignStatusFailsClosed(string payload, bool ok, Outcome expected) + { + var adapter = new TelesignProvider(); + using var json = JsonDocument.Parse(payload); + var parsed = adapter.ParseResponse(ok ? 200 : 500, ok, json.RootElement); + Assert.Equal(expected, OutcomeMapper.ResolveOutcome(adapter.Manifest, parsed)); + } + } internal sealed class TestEnv : Dictionary, IEnv diff --git a/javascript/README.md b/javascript/README.md index 293e006..be301f9 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -1,4 +1,4 @@ -# External Phone Provider Function — JavaScript +# External Phone Provider Function: JavaScript 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. diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index e33383e..4943982 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -324,13 +324,18 @@ async function sendViaProvider(providerEntry, dispatch, options) { return { httpStatus: 502, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; } - const providerRequest = adapter.buildRequest({ - channel, - endpoint: endpointBaseUrl, - dispatch, - credential, - env: config.env, - }); + let providerRequest; + try { + providerRequest = adapter.buildRequest({ + channel, + endpoint: endpointBaseUrl, + dispatch, + credential, + env: config.env, + }); + } catch { + return { httpStatus: 502, body: failBody(providerId, channel, 'provider request failed', dispatch, requestId) }; + } if (!isValidProviderUrl(providerRequest.url)) { return { httpStatus: 502, body: failBody(providerId, channel, 'provider request URL invalid', dispatch, requestId) }; diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index 5ba03a1..940a35c 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -28,43 +28,31 @@ const manifest = { }, }; -function buildRequest({ channel, endpoint, dispatch, credential, env }) { - const base = endpoint; - const contentType = 'application/x-www-form-urlencoded'; - const authorization = `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`; - - let path; - let params; - if (channel === 'voice') { - path = '/v1/voice'; - params = new URLSearchParams({ - phone_number: dispatch.destination, - message: dispatch.message, - message_type: 'OTP', - voice: env.TELESIGN_VOICE || 'f-en-US', - external_id: dispatch.correlationId || dispatch.messageId, - }); - } else { - path = '/v1/messaging'; - params = new URLSearchParams({ - phone_number: dispatch.destination, - message: dispatch.message, - sender_id: env.EPP_PROVIDER_ACCOUNT_NAME || '', - message_type: 'OTP', - external_id: dispatch.correlationId || dispatch.messageId, - is_primary: 'true', - }); +function buildRequest({ channel, endpoint, dispatch, credential }) { + if (!['sms', 'voice'].includes(channel)) throw new Error('unsupported channel'); + if (typeof dispatch.destination !== 'string' || !/^\+[1-9][0-9]{1,14}$/.test(dispatch.destination) + || dispatch.destination.trim() !== dispatch.destination) { + throw new Error('invalid recipient'); } - + const authorization = `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`; + const correlationId = typeof dispatch.correlationId === 'string' && dispatch.correlationId + ? dispatch.correlationId : dispatch.messageId; + const message = { text: dispatch.message }; + if (typeof dispatch.locale === 'string' && dispatch.locale.trim()) message.language = dispatch.locale; return { - url: `${base}${path}`, + url: `${endpoint.replace(/\/+$/, '')}/integration/msft/cyot`, method: 'POST', headers: { Authorization: authorization, - 'Content-Type': contentType, + 'Content-Type': 'application/json', Accept: 'application/json', }, - body: params.toString(), + body: JSON.stringify({ + recipient: { phone_number: dispatch.destination }, + message, + channels: [{ channel }], + correlation_id: correlationId, + }), }; } @@ -73,8 +61,9 @@ function parseResponse({ httpStatus, ok, json }) { return new ParsedResponse({ success: ok, providerHttpStatus: httpStatus, - providerMessageId: (json && json.reference_id) || null, - providerStatusCode: status.code != null ? String(status.code) : null, + providerMessageId: typeof json?.reference_id === 'string' ? json.reference_id : null, + providerStatusCode: Number.isInteger(status.code) ? String(status.code) : 'UNKNOWN', + providerStatusDescription: typeof status.description === 'string' ? status.description : null, }); } diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 28d7f51..63e35b2 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -107,18 +107,49 @@ test('App-auth SMS preserves its request and normalizes acceptance', () => { providerMessageId: 'message-id', providerStatusName: 'PENDING' })); }); -test('Basic-auth SMS preserves its form request and normalizes acceptance', () => { - const request = getProvider('telesign').adapter.buildRequest(input); - assert.equal(request.url, 'https://provider.example/v1/messaging'); - assert.equal(request.headers.Authorization, `Basic ${Buffer.from('id:key').toString('base64')}`); - assert.equal(request.headers['Content-Type'], 'application/x-www-form-urlencoded'); - assert.equal(new URLSearchParams(request.body).get('message'), dispatch.message); +test('Telesign EPP uses the same Basic-auth JSON contract for SMS and voice', () => { + for (const [channel, locale] of [['sms', 'en'], ['voice', 'en'], + ['sms', undefined], ['sms', ''], ['sms', { untrusted: true }]]) { + const request = getProvider('telesign').adapter.buildRequest({ ...input, channel, + endpoint: 'https://verify.telesign.com///', dispatch: { ...dispatch, locale } }); + assert.equal(request.url, 'https://verify.telesign.com/integration/msft/cyot'); + assert.equal(request.method, 'POST'); + assert.deepEqual(request.headers, { Authorization: `Basic ${Buffer.from('id:key').toString('base64')}`, + 'Content-Type': 'application/json', Accept: 'application/json' }); + assert.deepEqual(JSON.parse(request.body), { + recipient: { phone_number: dispatch.destination }, + message: locale === 'en' ? { text: dispatch.message, language: 'en' } : { text: dispatch.message }, + channels: [{ channel }], correlation_id: dispatch.correlationId, + }); + } const response = getProvider('telesign').adapter.parseResponse({ httpStatus: 200, ok: true, json: { reference_id: 'message-id', status: { code: 290 } } }); assert.deepEqual(response, new ParsedResponse({ success: true, providerHttpStatus: 200, providerMessageId: 'message-id', providerStatusCode: '290' })); }); +test('Telesign EPP rejects invalid recipients and fails closed on unknown status', () => { + const { adapter, manifest } = getProvider('telesign'); + for (const destination of ['15551234567', '+0123', '+1', '+1234567890123456', '+123\n', '+123\r', '+12 34', null]) { + assert.throws(() => adapter.buildRequest({ ...input, dispatch: { ...dispatch, destination } }), /invalid recipient/); + } + assert.throws(() => adapter.buildRequest({ ...input, channel: 'email' }), /unsupported channel/); + for (const correlationId of [undefined, null, '', 123, true, [], { invalid: true }]) { + const request = adapter.buildRequest({ ...input, dispatch: { ...dispatch, correlationId } }); + assert.equal(JSON.parse(request.body).correlation_id, dispatch.messageId); + } + for (const code of [undefined, null, {}, true, '290', 999]) { + const parsed = adapter.parseResponse({ httpStatus: 200, ok: true, json: { status: { code } } }); + assert.equal(resolveOutcome(manifest, parsed), 'Fail'); + } + for (const [code, ok, expected] of [[290, true, 'Continue'], [100, true, 'Continue'], [290, false, 'Fail']]) { + const parsed = adapter.parseResponse({ httpStatus: ok ? 200 : 500, ok, + json: { reference_id: 'reference', status: { code, description: 'status detail' } } }); + assert.equal(parsed.providerStatusDescription, 'status detail'); + assert.equal(resolveOutcome(manifest, parsed), expected); + } +}); + test('static-Bearer SMS preserves its batch request and normalizes acceptance', () => { const request = getProvider('sinch').adapter.buildRequest(input); assert.equal(request.url, 'https://provider.example/xms/v1/plan/batches'); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 0d79552..f0db1dc 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -179,6 +179,53 @@ test('SMS/voice preserve content and correlation without reflecting headers or l assert.equal(fetchMock.mock.callCount(), 2); }); +test('Telesign EPP sends decrypted SMS and voice content with Basic auth and private logs', async () => { + process.env.EPP_PROVIDER_NAME = 'telesign'; + process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com'; + for (const [channel, name, code] of [[1, 'sms', 290], [2, 'voice', 100]]) { + fetchMock.mock.mockImplementation(async () => ({ ok: true, status: 200, + text: async () => JSON.stringify({ reference_id: 'PRIVATE-REFERENCE', correlation_id: 'provider-correlation', + status: { code, description: 'PRIVATE-STATUS' } }) })); + const result = await invoke(await envelope({ channel }), { authorization: 'Bearer FORGED-TOKEN', 'x-shutter-mode': 'true' }); + assert.deepEqual(result.jsonBody, { nonce: delivery.nonce, correlationId: 'correlation-id', providerStatus: 'accepted' }); + assert.equal(result.status, 200); + const [url, init] = fetchMock.mock.calls.at(-1).arguments; + assert.equal(url, 'https://verify.telesign.com/integration/msft/cyot'); + assert.deepEqual(JSON.parse(init.body), { recipient: { phone_number: delivery.phoneNumber }, + message: { text: delivery.message, language: delivery.locale }, channels: [{ channel: name }], correlation_id: 'correlation-id' }); + assert.deepEqual(init.headers, { Authorization: `Basic ${Buffer.from('PRIVATE-API-KEY:PRIVATE-API-KEY').toString('base64')}`, + 'Content-Type': 'application/json', Accept: 'application/json' }); + assert.equal(init.redirect, 'manual'); + assert.doesNotMatch(JSON.stringify(logs), /PRIVATE|918273|15551234567|FORGED/); + assert.equal(result.jsonBody.reference_id, undefined); + } + assert.equal(fetchMock.mock.callCount(), 2); +}); + +test('Telesign evaluation never sends and invalid recipients never reach HTTP', async () => { + process.env.EPP_PROVIDER_NAME = 'telesign'; + process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com'; + for (const channel of [1, 2]) { + const result = await invoke(await envelope({ channel, mode: 2 })); + assert.equal(result.status, 200); + assert.equal(result.jsonBody.nonce, delivery.nonce); + } + assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); + assertFailure(await invoke(await envelope({}, { ...delivery, phoneNumber: '15551234567' })), 502); + assert.equal(fetchMock.mock.callCount(), 0); +}); + +test('Telesign missing status or upstream failure never acknowledges delivery', async () => { + process.env.EPP_PROVIDER_NAME = 'telesign'; + process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com'; + for (const [status, payload, expected] of [[200, {}, 502], [500, { status: { code: 290 } }, 502], + [429, { status: { code: 290 } }, 429]]) { + fetchMock.mock.mockImplementation(async () => ({ ok: status === 200, status, text: async () => JSON.stringify(payload) })); + assertFailure(await invoke(await envelope()), expected); + } + assert.equal(fetchMock.mock.callCount(), 3); +}); + test('handler awaits the provider body and returns 502/429 without a nonce or retries', async () => { for (const status of [500, 429]) { let release; diff --git a/python/README.md b/python/README.md index de7afdb..3fe5712 100644 --- a/python/README.md +++ b/python/README.md @@ -1,4 +1,4 @@ -# External Phone Provider Function — Python (v2 model) +# External Phone Provider Function: Python (v2 model) 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. diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py index 58bd95a..d364691 100644 --- a/python/src/providers/telesign.py +++ b/python/src/providers/telesign.py @@ -1,5 +1,6 @@ import base64 -import urllib.parse +import json +import re from ..models import ParsedResponse @@ -20,40 +21,47 @@ class TelesignProvider: } def build_request(self, channel, endpoint, dispatch, credential, env): + if channel not in ("sms", "voice"): + raise ValueError("unsupported channel") + if not isinstance(dispatch.destination, str) or not re.fullmatch(r"\+[1-9][0-9]{1,14}", dispatch.destination): + raise ValueError("invalid recipient") raw = f"{credential['identity']}:{credential['secret']}".encode() authorization = "Basic " + base64.b64encode(raw).decode() - - external_id = dispatch.correlation_id or dispatch.message_id - if channel == "voice": - path = "/v1/voice" - form = { - "phone_number": dispatch.destination, - "message": dispatch.message or "", - "message_type": "OTP", - "voice": env.get("TELESIGN_VOICE") or "f-en-US", - "external_id": external_id, - } - else: - path = "/v1/messaging" - form = { - "phone_number": dispatch.destination, - "message": dispatch.message or "", - "sender_id": env.get("EPP_PROVIDER_ACCOUNT_NAME") or "", - "message_type": "OTP", - "external_id": external_id, - "is_primary": "true", - } - - headers = {"Authorization": authorization, "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"} - return {"url": f"{endpoint}{path}", "method": "POST", "headers": headers, "body": urllib.parse.urlencode(form)} + correlation_id = dispatch.correlation_id + if not isinstance(correlation_id, str) or not correlation_id: + correlation_id = dispatch.message_id + message = {"text": dispatch.message} + if isinstance(dispatch.locale, str) and dispatch.locale.strip(): + message["language"] = dispatch.locale + body = { + "recipient": {"phone_number": dispatch.destination}, + "message": message, + "channels": [{"channel": channel}], + "correlation_id": correlation_id, + } + headers = { + "Authorization": authorization, + "Content-Type": "application/json", + "Accept": "application/json", + } + return { + "url": f"{endpoint.rstrip('/')}/integration/msft/cyot", + "method": "POST", + "headers": headers, + "body": json.dumps(body), + } def parse_response(self, http_status, ok, json_body): - status = json_body.get("status") or {} if isinstance(json_body, dict) else {} + payload = json_body if isinstance(json_body, dict) else {} + status = payload.get("status") + status = status if isinstance(status, dict) else {} code = status.get("code") + reference_id = payload.get("reference_id") + description = status.get("description") 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_description=status.get("description"), + provider_message_id=reference_id if isinstance(reference_id, str) else None, + provider_status_code=str(code) if type(code) is int else "UNKNOWN", + provider_status_description=description if isinstance(description, str) else None, ) diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index 9bf87f7..e7b7b73 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -1,11 +1,10 @@ import base64 import json from pathlib import Path -from urllib.parse import parse_qs import pytest -from src.dispatch import DispatchRequest, ProviderRegistry, context_to_dispatch, parse_envelope +from src.dispatch import DispatchRequest, ProviderRegistry, context_to_dispatch, parse_envelope, resolve_outcome from src.models import DeliveryContext, Envelope, ParsedResponse from src.providers.infobip import InfobipProvider from src.providers.sinch import SinchProvider @@ -57,19 +56,49 @@ def test_infobip_sms_request_and_response_contract(): assert response == ParsedResponse(True, 200, provider_message_id="message-id", provider_status_name="PENDING") -def test_telesign_sms_request_and_response_contract(): +@pytest.mark.parametrize("channel,locale", [ + ("sms", "en"), ("voice", "en"), ("sms", None), ("sms", ""), ("sms", {"untrusted": True}), +]) +def test_telesign_epp_request_contract(channel, locale): + dispatch = _dispatch(channel) + dispatch.locale = locale request = TelesignProvider().build_request( - "sms", "https://telesign.example", _dispatch(), + channel, "https://verify.telesign.com///", dispatch, {"mode": "apiKey", "secret": "key", "identity": "customer"}, {}, ) - assert request["method"] == "POST" and request["url"] == "https://telesign.example/v1/messaging" - assert request["headers"]["Authorization"] == "Basic " + base64.b64encode(b"customer:key").decode() - assert request["headers"]["Content-Type"] == "application/x-www-form-urlencoded" - form = parse_qs(request["body"]) - assert form["phone_number"] == ["+15551234567"] and form["message"] == [MESSAGE] - assert form["message_type"] == ["OTP"] and form["external_id"] == ["correlation-id"] - response = TelesignProvider().parse_response(200, True, {"reference_id": "message-id", "status": {"code": 290}}) + assert request["method"] == "POST" and request["url"] == "https://verify.telesign.com/integration/msft/cyot" + assert request["headers"] == {"Authorization": "Basic " + base64.b64encode(b"customer:key").decode(), + "Content-Type": "application/json", "Accept": "application/json"} + assert json.loads(request["body"]) == { + "recipient": {"phone_number": "+15551234567"}, + "message": {"text": MESSAGE, "language": "en"} if locale == "en" else {"text": MESSAGE}, + "channels": [{"channel": channel}], "correlation_id": "correlation-id", + } + + +def test_telesign_epp_validates_recipients_and_status(): + adapter = TelesignProvider() + response = adapter.parse_response(200, True, {"reference_id": "message-id", "status": {"code": 290}}) assert response == ParsedResponse(True, 200, provider_message_id="message-id", provider_status_code="290") + credential = {"identity": "customer", "secret": "key"} + for destination in ("15551234567", "+0123", "+1", "+1234567890123456", "+123\n", "+123\r", "+12 34", None): + dispatch = _dispatch() + dispatch.destination = destination + with pytest.raises(ValueError, match="invalid recipient"): + adapter.build_request("sms", "https://verify.telesign.com", dispatch, credential, {}) + with pytest.raises(ValueError, match="unsupported channel"): + adapter.build_request("email", "https://verify.telesign.com", _dispatch(), credential, {}) + dispatch = _dispatch() + for correlation_id in (None, "", 123, True, [], {"invalid": True}): + dispatch.correlation_id = correlation_id + request = adapter.build_request("sms", "https://verify.telesign.com", dispatch, credential, {}) + assert json.loads(request["body"])["correlation_id"] == dispatch.message_id + for payload in (None, {}, {"status": []}, {"status": {"code": True}}, {"status": {"code": "290"}}, {"status": {"code": 999}}): + assert resolve_outcome(adapter.manifest, adapter.parse_response(200, True, payload)) == "Fail" + for code, ok, outcome in ((290, True, "Continue"), (100, True, "Continue"), (290, False, "Fail")): + parsed = adapter.parse_response(200 if ok else 500, ok, {"status": {"code": code, "description": "status detail"}}) + assert parsed.provider_status_description == "status detail" + assert resolve_outcome(adapter.manifest, parsed) == outcome def test_sinch_sms_request_and_response_contract():