diff --git a/.gitignore b/.gitignore index 9d58729..23efa09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,55 @@ # Dependencies node_modules/ +# Local helper scripts +scripts/ + # Python .venv/ venv/ *.pyc .pytest_cache/ -# Azure Functions local settings — may contain secrets; never commit. -local.settings.json +# Local secrets (keep the shared sample) +local.settings*.json +!local.settings.sample.json +.env +.env.* +!.env.example +.keys/ -# Keys / certificates — never commit private keys. +# Keys and publish credentials *.pem *.pfx +*.key +*.p12 +*.publishsettings +*.pubxml +*.pubxml.user # Azure Functions build/runtime -bin/ -obj/ +[Bb]in/ +[Oo]bj/ +publish/ +artifacts/ .azure/ .python_packages/ __pycache__/ +coverage/ +[Tt]est[Rr]esults*/ + +# .NET developer output +[Dd]ebug*/ +[Rr]elease*/ +*.nupkg +*.snupkg +*.suo +*.user +*.userprefs +*.userosscache +*.sln.docstates +_ReSharper*/ +*.DotSettings.user # Logs *.log @@ -27,5 +57,7 @@ npm-debug.log* # Editor / OS .vscode/ +.vs/ +.idea/ .DS_Store Thumbs.db diff --git a/README.md b/README.md index 524eefc..a0e0b8e 100644 --- a/README.md +++ b/README.md @@ -9,39 +9,54 @@ secrets in Key Vault. | Language | Status | Folder | |----------|--------|--------| -| JavaScript (Node.js) | ✅ Available | [`javascript/`](javascript/) | -| C# (.NET isolated worker) | ✅ Available | [`dotnet/`](dotnet/) | -| Python (v2 model) | ✅ Available | [`python/`](python/) | +| JavaScript (Node.js) | Available | [javascript/](javascript/) | +| C# (.NET isolated worker) | Available | [dotnet/](dotnet/) | +| 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. +Choose one language and configure the adapter for your provider. No provider is preferred or selected +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, and deploying, step by step. ## The design in one line -`POST /api/SendOtp` → validate token → resolve provider → fetch secret from Key Vault (managed -identity) → provider adapter builds the request → send with a timeout → map the provider status to an -outcome and an HTTP status. **Fail-closed:** only a `Continue` outcome returns `200 accepted`. +SAS → Easy Auth → anonymous HTTP handler (`POST /api/SendOtp`, validate envelope + decrypt JWE) → +configured provider (API key) → 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. + +See [docs/CONTRACT.md](docs/CONTRACT.md) for the full specification every implementation follows. -See [`docs/CONTRACT.md`](docs/CONTRACT.md) for the full specification every implementation follows. +Request `tenantId`, `channel`, `mode` and `ttlSeconds` are request data, not extra environment settings. +The trusted tenant issuer, endpoint-app audience and authorized SAS caller are configured in Easy Auth, +not in application environment settings or incoming request data. ## Security -**Turn Easy Auth (App Service Authentication) ON — that is the primary gate.** Set -`unauthenticatedClientAction` to `Return401` and pin `allowedApplications` to Microsoft's app id. The -HTTP trigger is `authLevel: anonymous`, so with Easy Auth off nothing stands in front of the endpoint. +**Easy Auth (App Service Authentication) is the only caller-authentication gate, before the anonymous +Function.** Enable it with `requireAuthentication=true`, `unauthenticatedClientAction=Return401` and +`requireHttps=true`. Configure the trusted tenant issuer and `allowedAudiences` for the endpoint app, +and a **nonempty `allowedApplications`** list pinned to the authorized SAS caller application ID. +Do not exclude the SendOtp path. The handler does not parse or validate bearer tokens, and there is +no backup application validation or function-key gate. **Never expose this endpoint to the public +internet with Easy Auth disabled or bypassed.** See [platform setup](docs/ONBOARDING.md#2-provision-encryption-and-deployment-trust). + +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. -**Also set `EPP_REQUIRE_AUTH=true` in any real deployment.** Easy Auth lives outside the code, so a -portal change or slot swap can drop it silently; in-process validation is the backstop. The Function -then validates the caller's **Entra JWT** (audience = `EPP_EXPECTED_AUDIENCE`, issuer tenant = -`EPP_TENANT_ID`, signature via JWKS) and returns **401** without a valid token. Provider secrets are read -from **Key Vault** via **managed identity** — no keys or connection strings in code or config. Locally -(`func start`) there is no Easy Auth, so `EPP_REQUIRE_AUTH` is the only gate. See -[docs/ONBOARDING.md §5](docs/ONBOARDING.md) for how to test it with a token. +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 +authentication; [separate deployed security checks](docs/ONBOARDING.md#4-package-deploy-and-verify) are required. ## Docs @@ -53,4 +68,4 @@ from **Key Vault** via **managed identity** — no keys or connection strings in - **New provider** (in any language): add one adapter file exposing `manifest` + `buildRequest` + `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). + and wire it into [.github/workflows/ci.yml](.github/workflows/ci.yml). diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 233e3f2..7fe7d82 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,66 +1,88 @@ # External Phone Provider Function — Language-Agnostic Contract -This is the **source of truth** every language implementation (`javascript/`, `dotnet/`, `python/`) -must conform to. If an implementation disagrees with this document, the implementation is wrong. +This defines the shared contract for [JavaScript](../javascript/), [Python](../python/) and +[.NET](../dotnet/). See [production limitations](#production-limitations) before production use. > **Naming.** "CYOT" (Choose Your Own Telecom) is the internal code name for this feature. It still > appears in wire-level identifiers that must not change — type names (`SendCyotOtpRequest`, > `CyotDeliveryContext`) and the caller's `User-Agent`. App settings use the `EPP_` prefix. -The design is intentionally simple: **one dispatch engine + drop-in provider adapters**. Adding a -provider is adding one adapter file; adding a language is re-implementing this contract. +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. --- ## 1. HTTP API -**Endpoint:** `POST /api/SendOtp` (Functions HTTP trigger, `authLevel: anonymous`; trust comes from -the Entra token when `EPP_REQUIRE_AUTH=true`). This is the interface **SAS (StrongAuthenticationService)** -calls. PII (phone number + the rendered message, which contains the passcode) is **encrypted** inside a -JWE; the cleartext envelope carries routing/scheduling only. +**Endpoint:** `POST /api/SendOtp` (Functions HTTP trigger, `authLevel: anonymous`). **Easy Auth is the +only caller-authentication boundary and runs before the handler**; the application has no token +validator or function-key gate. This is the interface **SAS (StrongAuthenticationService)** calls. +SAS sends an Entra bearer JWT as part of its protocol; Easy Auth validates it, not the handler. +PII (phone number + the rendered message, which contains the passcode) is **encrypted** inside a JWE; +the cleartext envelope carries routing/scheduling only. ### Request headers | Header | Notes | |--------|-------| -| `Authorization` | `Bearer ` (audience = `EPP_EXPECTED_AUDIENCE`) | -| `User-Agent` | e.g. `Microsoft-AzureMFA-SAS-CYOT/1.0` (logged) | -| `x-ms-correlation-id` | sign-in correlation id (fallback for envelope `correlationId`) | -| `x-ms-client-request-id` | per-attempt id (used as `messageId`) | +| `Authorization` | consumed by platform authentication, not parsed or echoed by handler | +| `User-Agent` | e.g. `Microsoft-AzureMFA-SAS-CYOT/1.0`; not logged | +| `x-ms-correlation-id` | tracing only; fallback for envelope `correlationId`, not authentication | +| `x-ms-client-request-id` | per-attempt tracing id (used as `messageId`), not authentication | + +Forwarded headers, including `x-ms-client-principal`, do not establish trust by themselves and cannot +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. ### Request body — `SendCyotOtpRequest` (cleartext envelope) | Field | Required | Notes | |-------|----------|-------| -| `type` | ✅ | envelope contract version, e.g. `microsoft.mfa.otpDeliver.v1` | -| `tenantId` | | opaque routing guid (says nothing about the tenant) | -| `correlationId` | | sign-in correlation; stitches SAS ↔ provider traces | -| `channel` | ✅ | `CyotChannel` int: `1`=Sms, `2`=Voice (`0`=Undefined); the string forms `sms`/`voice` are also accepted | -| `mode` | ✅ | `CyotDeliveryMode` int: `1`=Live, `2`=Evaluation (rehearsal — do **NOT** deliver); the string forms `live`/`evaluation` are also accepted | -| `ttlSeconds` | | passcode validity remaining; `<= 0` is **logged as a warning** — the delivery still proceeds | -| `encryptedDeliveryContext` | ✅ | JWE compact serialization (see below) | +| `type` | yes | exactly `microsoft.mfa.otpDeliver.v1`; unknown versions are rejected | +| `tenantId` | no | opaque request routing metadata; never selects the trusted issuer, signing keys or provider | +| `correlationId` | no | sign-in tracing metadata | +| `channel` | yes | request delivery channel: `1`/`sms` or `2`/`voice`; not deployment configuration | +| `mode` | yes | request delivery mode: `1`/`live` or `2`/`evaluation`; evaluation does not deliver | +| `ttlSeconds` | no | positive JSON integer, at most `2147483647`; null, booleans, strings, fractions and nonpositive values are rejected. Use canonical integer notation (`60`, not `60.0` or `6e1`) across runtimes | +| `encryptedDeliveryContext` | yes | JWE compact serialization (see below) | + +Unknown `type`, invalid `ttlSeconds`, unsupported `channel` or `mode`, or missing/empty `encryptedDeliveryContext` → `400`. Arrays, objects and booleans are not channel/mode values. -`channel` not in `{1,2}`/`{sms,voice}` → `400`. `mode` not in `{1,2}`/`{live,evaluation}` → `400`. Missing/empty `encryptedDeliveryContext` → `400`. +These are request data, not settings to provision. The TTL check validates the supplied value; it +does not verify passcode expiry or implement a delivery deadline. Deployment trust comes only from +the required platform authentication and caller allowlist, not the body or tracing headers. ### `encryptedDeliveryContext` (JWE) -Alg: **RSA-OAEP-256** (CEK wrap) + **A256GCM** (content). The JOSE protected header carries `kid`; the -endpoint resolves the matching RSA private key (`EPP_DECRYPTION_KEY_PEM`, a Key Vault reference) and -local dev) and decrypts. The compact JWE must have **exactly five non-empty segments** and stay within a -size limit; `alg`/`enc` are pinned (only `RSA-OAEP-256` + `A256GCM` accepted) and the AES-GCM auth tag is +Alg: **RSA-OAEP-256** (CEK wrap) + **A256GCM** (content). The JOSE protected header carries `kid`; +this sample uses the single configured RSA private key (`EPP_DECRYPTION_KEY_PEM`, a Key Vault +reference in Azure), not a multi-key lookup. The compact JWE must have **exactly five non-empty +segments** and at most **16,384 characters**; `alg`/`enc` are pinned (only `RSA-OAEP-256` + `A256GCM` accepted) and the AES-GCM auth tag is verified before any plaintext is used. Decrypted plaintext = `CyotDeliveryContext`: +The original compact JWE is passed unchanged to the JOSE library. Parsing header fields for the +advisory key-ID check must not replace the original protected-header bytes used for authentication. + +All three HTTP-handler suites use [shared policy cases](../tests/fixtures/contract.json): the allowed +pair succeeds, while `RSA-OAEP`, `A128GCM` and `A256CBC-HS512` alternatives return `400 decryption_failed` +without provider I/O. Decryption uses the same policy before live/evaluation branching, so the matrix +runs once per language. Tag tampering and original-header-byte tests remain. + | Field | Required | Notes | |-------|----------|-------| -| `nonce` | ✅ | value the endpoint MUST echo to prove decryption | -| `phoneNumber` | ✅ | E.164, single canonical string | -| `message` | ✅ | fully rendered + localized text; **contains the passcode**. For `voice`, the passcode digits are spaced so TTS reads them individually | -| `extension` | | office voice only | -| `locale` | | selects TTS voice for the voice channel | -| `riskContext` | | `CyotRiskContext` (scenario, familiarity flags, ip/asn/geo, ja4/ja4h, …) | +| `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 | +| `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 | Decryption failure → `400`. Missing `nonce` / `phoneNumber` / `message` → `400`. +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. + ### Response — `CyotEndpointResponse` (JSON) ```json @@ -72,12 +94,39 @@ The endpoint returns **`200`** on acceptance (any `2xx` counts as transport acce matching nonce**, SAS treats the send as handled. **Nonce mismatch / non-2xx / timeout → SAS falls back to native CAPP delivery.** `Evaluation` mode returns `200` + nonce echo without delivering. +Live handlers await provider acceptance; they do not launch background delivery after replying. +Handler failures omit the nonce and accepted status and return a sanitized error with a request ID +and, after envelope processing, a correlation ID. Platform rejections happen before the handler and +do not use this application response contract. + +Validation failures return `error: "bad_request"` and a fixed `reason` in every language. Envelope +checks run in this order: object shape, version, encrypted-context presence, channel, mode, TTL. +Reasons are `invalid JSON body`, `invalid envelope`, `unsupported envelope type`, +`encryptedDeliveryContext is required`, `unsupported channel`, `unsupported mode`, `invalid ttlSeconds` +or `ttlSeconds expired`. A decrypted context missing a required nonblank string returns +`incomplete delivery context`. Reasons never include supplied values or exception text. JWE failures +return `error: "decryption_failed"` without a cryptographic reason or nonce. + +### Evaluation (generic shutter) + +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 +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. + +There is no diagnostic environment flag. A live request is not an evaluation request. Adapter-specific +wire fields, where required by an API, remain internal and cannot enable a separate non-delivery mode. + --- ## 2. Outcome → HTTP status mapping The provider's parsed status is mapped via the adapter's `responseMapping` to an **outcome**, then to 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. | Outcome | HTTP | When | |---------|------|------| @@ -98,15 +147,17 @@ an HTTP status. **Fail-closed:** an unknown/unmapped status is treated as `Fail` Each provider is one unit exposing three things: - **`manifest`** — protocol facts only: - - `id` — provider id (also the `Provider` value; endpoint app setting is `_ENDPOINT`) - - `auth` — `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }` or `{ mode: 'oauth2' }` + - `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 })`** → `{ success, providerHttpStatus, providerMessageId, providerStatusName | providerStatusCode, providerStatusDescription }` -The engine auto-discovers adapters (a `providers/` folder or registration). Endpoints, senders, TTLs, -etc. are **not** in the manifest — they are app settings (see §4). +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. --- @@ -116,24 +167,40 @@ Set by provisioning. **Identical names across all languages.** | Key | Purpose | |-----|---------| -| `EPP_PROVIDER_NAME` | active provider id (`infobip` \| `telesign` \| `sinch` \| `soprano`) | -| `EPP_PROVIDER_ENDPOINT` | provider base URL (one provider is active per deployment) | -| `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider | -| `EPP_PROVIDER_TIMEOUT_MS` | outbound call timeout (default 1500) | -| `EPP_DECRYPTION_KEY_PEM` | RSA private key for JWE decryption — PEM, or **base64 over the PEM** as the setup script writes it. A **Key Vault reference** in Azure | -| `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | -| `EPP_REQUIRE_AUTH` | `true` → validate the Entra token in-process. **Recommended `true` in every deployment**; Easy Auth is the primary gate, this is the backstop | -| `EPP_EXPECTED_AUDIENCE` | v1 token `aud` — the identifier URI `api://{host}/{appId}` | -| `EPP_EXPECTED_ISSUER` | v1 issuer `https://sts.windows.net/{tenantId}/` | -| `EPP_TENANT_ID` | your Entra tenant id | -| `EPP_EXPECTED_CLIENT_ID` | caller `appid`/`azp` to admit — Microsoft's app `25ec60fa-f18d-41a4-b398-50044c90ce13`. Enforced by Easy Auth (`403`) and, when `EPP_REQUIRE_AUTH=true`, against the token's own claim (`401`) | -| `EPP_LOG_PLAINTEXT` | **diagnostics only** — `true` writes the phone number and passcode to the log. Never enable in production | +| `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_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 | +| `EPP_ENCRYPTION_KEY_ID` | optional expected JWE `kid`; after successful decryption, a mismatch emits only `encryption_key_id_mismatch`. Advisory, not a key selector or authentication check | | `KEY_VAULT_URL` | Key Vault URI (provider API keys) | | `AZURE_CLIENT_ID` | set for a user-assigned managed identity | -**Secrets** (provider API keys, identity secrets like customer/api ids) live in **Key Vault**, referenced -by name in the manifest and fetched at runtime via **managed identity** (needs the *Key Vault Secrets -User* role). Never in code or config. +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. + +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 +`tenantId` is routing metadata and cannot select any of these. There is no application host-detection +guard or backup token validation. See [platform onboarding](ONBOARDING.md#2-provision-encryption-and-deployment-trust). + +### Default provider and configuration readers + +Provision `EPP_PROVIDER_NAME` with the customer's selected provider, plus that account's +`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. + +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 expose encryption, Key Vault and selected-provider settings, not caller-authentication settings. +Provider-specific options remain ordinary app settings passed to the selected adapter. + +All customers call the same `POST /api/SendOtp` handler in their chosen language. Its registry selects +the configured adapter, which builds the provider's SMS or voice API call. Purchasing an unsupported +provider does not install an adapter: add and register that provider's adapter first. Purchase, +subscription activation and changing tenant policy belong to provisioning, not this Function. --- @@ -142,30 +209,65 @@ User* role). Never in code or config. - **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** — the OTP code and phone number must **never** appear in logs or the response body (they - appear only in the outbound provider request, which is the delivery itself). The single exception is - `EPP_LOG_PLAINTEXT=true`, a **diagnostics-only** switch that logs the phone number, message, and - passcode. It defaults to false and **must not be enabled in production**. -- **Auth** — **Easy Auth must be ON** (`unauthenticatedClientAction=Return401`, `allowedApplications` - pinned to Microsoft's app); the trigger is `authLevel: anonymous`, so it is the primary gate. - `EPP_EXPECTED_CLIENT_ID` mismatches return `403`. Deployments should **also** set - `EPP_REQUIRE_AUTH=true` to validate the Entra JWT in-process (audience = `EPP_EXPECTED_AUDIENCE`, - issuer tenant = `EPP_TENANT_ID`, RS256, JWKS). No-op pass-through when false (local dev). +- **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, + elapsed milliseconds and evaluation flag. Original wire correlation IDs and the required nonce + echo remain unchanged. Hashes are pseudonymous, not anonymous; restrict log access and retention. + 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`, + `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. + The trigger remains `authLevel: anonymous`; there is no application token validation or function-key + fallback. The platform rejects unauthenticated requests with `401` and denies callers outside the + allowlist before the handler. **Never expose the endpoint to the public internet with Easy Auth off + 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 + 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. --- -## 6. Conformance test scenarios - -Every implementation ships tests covering at least: - -1. Each provider builds an HTTPS request with the code present and the correct auth scheme. -2. `Block` → 403; provider 4xx `Fail` → 400; 429 → 429; 401/403 → 401. -3. Provider HTTP 200 with an **unknown** status still `Fail`s (fail-closed). -4. Missing provider credential → 502; missing endpoint config → 502. -5. Timeout → 504; network error → 502. -6. Envelope validation: `400` on invalid JSON, unsupported `channel`, unsupported `mode`, missing - `encryptedDeliveryContext`, decryption failure, and an incomplete delivery context. -7. JWE round-trip: a context encrypted with RSA-OAEP-256 + A256GCM decrypts to the expected - `nonce` / `phoneNumber` / `message`, and the response echoes the `nonce`. -8. `Evaluation` mode → 200 + nonce echo, nothing sent. -9. Privacy: OTP code and phone never in logs or response body. +## 6. Lightweight tests + +Each language keeps lightweight offline tests covering representative application checks for: + +- Bundled adapter request formats and static provider credentials. +- Fail-closed outcomes, missing credentials, HTTPS guards and timeouts. +- Envelope validation and real JWE decryption/tamper rejection. +- Evaluation without provider I/O. +- Awaited delivery, nonce acknowledgement and privacy-safe logging. + +The sample deliberately omits exhaustive input permutations and SDK internals. These tests use +local keys and mocked external services; they do not send SMS and **do not test Easy Auth or platform +authorization**. Separate deployed security tests are required for missing/invalid credentials, +wrong issuer or audience, unauthorized caller, HTTPS enforcement and SendOtp route protection. +An authorized evaluation must succeed without provider I/O. These checks do not replace provider +integration or handset-delivery checks. See [deployment verification](ONBOARDING.md#4-package-deploy-and-verify). + +## Production limitations + +This is a sample, not production certification. Successful provider acceptance and nonce checks do +not prove handset delivery or support for every provider feature. + +- The Function imports one private PEM through a Key Vault secret reference and decrypts in-process; + vault-resident cryptographic operations and overlapping key rotation are not implemented. +- The Preview 1 setup guide requires asynchronous delivery after acceptance. This sample still waits + for the provider and has no durable queue or automatic retry implementation; it does not satisfy that + 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. +- 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. + +See [setup compatibility](ONBOARDING.md#setup-script-compatibility) for credential provisioning, +endpoint format and unsupported setup options. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 51817da..d91f705 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -1,58 +1,173 @@ # Customer Onboarding -A high-level guide to setting up, securing, and deploying the External Phone Provider OTP Function. The -steps are the same for every language (`javascript/`, `dotnet/`, `python/`); only the build/run commands -differ (see each language's README). All config keys, Key Vault secret names, and behaviors are defined -once in [CONTRACT.md](CONTRACT.md). - -## 1. Pick a language and a provider - -Choose an implementation folder and the SMS/voice provider you have an account with (Infobip, -Telesign, Soprano, Sinch). One provider is active per deployment. - -## 2. Store the provider secret in Key Vault - -Provider API keys never live in code or app settings — put them in **Key Vault** under the names the -adapter expects (see [CONTRACT.md §3](CONTRACT.md)). The Function reads them at runtime via its -**managed identity**, which needs the *Key Vault Secrets User* role on the vault. - -## 3. Configure - -Set the app settings from [`local.settings.sample.json`](local.settings.sample.json) — locally in a -`local.settings.json` file, in Azure as environment variables. The keys are identical across languages; -the full catalog is in [CONTRACT.md §4](CONTRACT.md). - -## 4. Run and send a test - -Build/run per the language README, then `POST /api/SendOtp` with the cleartext envelope (the PII lives -in the encrypted JWE — see [CONTRACT.md](CONTRACT.md)). A **`200`** with the echoed `nonce` -(`{ "nonce": "", "correlationId": "", "providerStatus": "accepted" }`) means the provider -**queued** it — delivery is asynchronous, so confirm via the provider's delivery report. - -## 5. Secure it — turn Easy Auth ON - -**Enable App Service Authentication (Easy Auth) on the Function App. This is the recommended posture and -the primary gate** — the trigger itself is `authLevel: anonymous`, so with Easy Auth off the endpoint is -open to the internet. Configure: - -- `unauthenticatedClientAction` = **`Return401`** -- `allowedApplications` = Microsoft's CYOT application id (`EPP_EXPECTED_CLIENT_ID`) - -Anything else is then rejected before your code runs. - -**Also set `EPP_REQUIRE_AUTH=true`.** Easy Auth is configured outside the code, so a portal change, slot -swap, or redeploy can silently drop it and nothing in the app would notice. In-process validation of the -**Entra JWT** (plus `EPP_EXPECTED_AUDIENCE` and `EPP_TENANT_ID`) is the backstop that fails closed if that -happens, and it is the only auth available when running locally with `func start`. - -To test, obtain a token for the expected audience and confirm: no token → 401, valid token → 200. - -## 6. Deploy - -Publish the chosen language folder to a Function App (see its README). Ensure the app's managed -identity has Key Vault access and the same environment variables are set. - -## 7. Add another provider - -One adapter file — `manifest` + `buildRequest` + `parseResponse` — then store its secret in Key Vault -and set its endpoint app setting. No engine changes. See [CONTRACT.md §3](CONTRACT.md). +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. Keep local settings private and set the +same shared values in the Function App environment for deployment; 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. + +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. diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index 2181368..2641b20 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -1,27 +1,18 @@ { - "_comment": "Reference app settings for any implementation. Copy the Values into your local.settings.json (set FUNCTIONS_WORKER_RUNTIME to 'node', 'dotnet-isolated', or 'python') or into the Function App's environment variables. Keys are the same across languages — see CONTRACT.md §4. Provider API keys are NOT here; they live in Key Vault. EPP_DECRYPTION_KEY_PEM is a Key Vault reference in Azure.", + "_comment": "Choose one runtime and a registered adapter. Credential values stay in Key Vault. Azure requires Easy Auth with authentication required and an allowed caller; configure it separately. Local hosts are unauthenticated and must stay on loopback.", "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "node | dotnet-isolated | python", - "EPP_DECRYPTION_KEY_PEM": "", - "EPP_ENCRYPTION_KEY_ID": "", + "EPP_DECRYPTION_KEY_PEM": "", + "EPP_ENCRYPTION_KEY_ID": "", - "_comment_auth": "Enable Easy Auth on the Function App (unauthenticatedClientAction=Return401, allowedApplications pinned to EPP_EXPECTED_CLIENT_ID) AND set EPP_REQUIRE_AUTH=true in any real deployment. false is for local func start only.", - "EPP_REQUIRE_AUTH": "false", - "EPP_EXPECTED_AUDIENCE": "api:///", - "EPP_EXPECTED_CLIENT_ID": "25ec60fa-f18d-41a4-b398-50044c90ce13", - "EPP_EXPECTED_ISSUER": "https://sts.windows.net//", - "EPP_TENANT_ID": "", - - "EPP_PROVIDER_NAME": "", - "EPP_PROVIDER_ENDPOINT": "https://", - "EPP_PROVIDER_ACCOUNT_NAME": "", + "EPP_PROVIDER_NAME": "", + "EPP_PROVIDER_ENDPOINT": "https:///", + "EPP_PROVIDER_ACCOUNT_NAME": "", + "_comment_provider_timeout": "Outbound provider HTTP timeout: ASCII decimal milliseconds, default 1500, cap 2500. Not a total invocation deadline; see CONTRACT.md. Provider URLs must be HTTPS; redirects are not followed.", "EPP_PROVIDER_TIMEOUT_MS": "1500", - "_comment_log_plaintext": "DIAGNOSTICS ONLY. true writes the phone number and passcode to the log. Never enable in production.", - "EPP_LOG_PLAINTEXT": "false", - - "KEY_VAULT_URL": "https://.vault.azure.net/" + "KEY_VAULT_URL": "https://.vault.azure.net/" } } diff --git a/dotnet/.funcignore b/dotnet/.funcignore new file mode 100644 index 0000000..68d0684 --- /dev/null +++ b/dotnet/.funcignore @@ -0,0 +1,12 @@ +local.settings*.json +**/local.settings*.json +.env* +**/.env* +.keys/ +**/.keys/ +**/*.pem +**/*.pfx +**/*.key +tests/ +obj/ +.vscode/ \ No newline at end of file diff --git a/dotnet/.gitignore b/dotnet/.gitignore deleted file mode 100644 index ff5b00c..0000000 --- a/dotnet/.gitignore +++ /dev/null @@ -1,264 +0,0 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. - -# Azure Functions localsettings file -local.settings.json - -# User-specific files -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ - -# Visual Studio 2015 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# DNX -project.lock.json -project.fragment.lock.json -artifacts/ - -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -#*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# The packages folder can be ignored because of Package Restore -**/packages/* -# except build/, which is used as an MSBuild target. -!**/packages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config -# NuGet v3's project.json files produces more ignoreable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -node_modules/ -orleans.codegen.cs - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -*.mdf -*.ldf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush -.cr/ - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc \ No newline at end of file diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index c201a67..83d14f4 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -1,5 +1,7 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; @@ -7,93 +9,44 @@ namespace Epp.Otp; -// HTTP trigger: POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the -// caller, parses the cleartext routing envelope, decrypts the JWE delivery context, dispatches to the -// provider, and echoes the nonce to prove decryption. Every line is tagged [EPP] so one filter pulls a -// whole delivery. +// Echo the nonce only on acceptance; log one PII-safe summary per invocation. public sealed class SendOtp { - private const string Tag = "[EPP]"; - private readonly DispatchEngine _engine; - private readonly TokenValidator _tokens; private readonly JweDecryptor _decryptor; private readonly IEnv _env; private readonly ILogger _log; - public SendOtp(DispatchEngine engine, TokenValidator tokens, JweDecryptor decryptor, IEnv env, ILogger log) + public SendOtp(DispatchEngine engine, JweDecryptor decryptor, IEnv env, ILogger log) { _engine = engine; - _tokens = tokens; _decryptor = decryptor; _env = env; _log = log; } - // Easy Auth has already validated the token; this records which identity arrived. - private static string? ReadCallerAppId(HttpRequest req) - { - var encoded = req.Headers["x-ms-client-principal"].FirstOrDefault(); - if (string.IsNullOrEmpty(encoded)) return null; - try - { - using var doc = JsonDocument.Parse(Convert.FromBase64String(encoded)); - if (!doc.RootElement.TryGetProperty("claims", out var claims) || claims.ValueKind != JsonValueKind.Array) - return null; - foreach (var claim in claims.EnumerateArray()) - { - var type = claim.TryGetProperty("typ", out var t) ? t.GetString() : null; - if (type is "appid" or "azp") - return claim.TryGetProperty("val", out var v) ? v.GetString() : null; - } - return null; - } - catch - { - return null; - } - } - - // Left alone, TTS reads 641895 as "six hundred forty-one thousand...", which no user can type. - private static string? SpacePasscodeForVoice(string? message) => - string.IsNullOrEmpty(message) ? message : Regex.Replace(message, @"\b\d{4,8}\b", m => string.Join(" ", m.Value.ToCharArray())); - + // Anonymous at the Functions layer; EasyAuth must remain enabled and require authentication in the cloud. [Function("SendOtp")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "SendOtp")] HttpRequest req) { - var started = DateTimeOffset.UtcNow; + var started = Stopwatch.StartNew(); var requestId = Guid.NewGuid().ToString("n"); - var clientRequestId = req.Headers["x-ms-client-request-id"].FirstOrDefault() ?? requestId; - var headerCorrelationId = req.Headers["x-ms-correlation-id"].FirstOrDefault(); - var logPlaintext = string.Equals(_env.Get("EPP_LOG_PLAINTEXT"), "true", StringComparison.OrdinalIgnoreCase); - var expectedKeyId = _env.Get("EPP_ENCRYPTION_KEY_ID"); - var expectedClientId = _env.Get("EPP_EXPECTED_CLIENT_ID"); + var correlationId = requestId; + var httpStatus = 500; + var evaluation = false; - void Log(string label, object? value) => _log.LogInformation("{Tag} {Label}: {Value}", Tag, label.PadRight(18), value); - - _log.LogInformation("{Tag} ======== delivery received ========", Tag); - Log("invocation", requestId); + ObjectResult Reply(int status, object body) + { + httpStatus = status; + return new ObjectResult(body) { StatusCode = status }; + } - string? correlationId = null; try { - var callerAppId = ReadCallerAppId(req); - Log("caller appid", callerAppId ?? "none (Easy Auth off, or called directly)"); - - if (callerAppId is not null && !string.IsNullOrEmpty(expectedClientId) && callerAppId != expectedClientId) - { - _log.LogError("{Tag} caller {Caller} is not {Expected}. Easy Auth allowedApplications is not doing its job.", - Tag, callerAppId, expectedClientId); - return new ObjectResult(new { error = "unexpected_caller" }) { StatusCode = 403 }; - } - - var auth = await _tokens.ValidateAsync(req.Headers.Authorization.FirstOrDefault()); - if (!auth.Ok) - { - _log.LogError("{Tag} token rejected: {Reason}", Tag, auth.Reason); - return new ObjectResult(new { error = "unauthorized", reason = auth.Reason, requestId }) { StatusCode = 401 }; - } + var config = AppConfig.Read(_env); + var clientRequestId = req.Headers["x-ms-client-request-id"].FirstOrDefault() ?? requestId; + correlationId = req.Headers["x-ms-correlation-id"].FirstOrDefault() ?? requestId; JsonElement payload; try @@ -103,110 +56,64 @@ public async Task Run( } catch { - _log.LogError("{Tag} body is not JSON", Tag); - return new BadRequestObjectResult(new { error = "bad_request", reason = "invalid JSON body", requestId }); + return Reply(400, new { error = "bad_request", reason = "invalid JSON body", requestId }); } var (envelope, envelopeError) = EnvelopeParser.Parse(payload); if (envelopeError is not null) - { - _log.LogError("{Tag} envelope rejected: {Reason}", Tag, envelopeError); - return new BadRequestObjectResult(new { error = "bad_request", reason = envelopeError, requestId }); - } - - Log("type", envelope!.Type); - Log("tenantId", envelope.TenantId); - Log("correlationId", envelope.CorrelationId); - Log("channel", envelope.Channel); - Log("mode", envelope.Mode); - Log("ttlSeconds", envelope.TtlSeconds); + return Reply(400, new { error = "bad_request", reason = envelopeError, requestId }); - correlationId = envelope.CorrelationId ?? headerCorrelationId ?? requestId; - - // Surfaced rather than swallowed: the passcode expires before it can be used. - if (envelope.TtlSeconds is <= 0) - _log.LogWarning("{Tag} ttlSeconds is {Ttl}; the passcode has expired.", Tag, envelope.TtlSeconds); + correlationId = envelope!.CorrelationId ?? correlationId; + evaluation = envelope.Mode == EnvelopeParser.ModeEvaluation; JweResult decrypted; try { decrypted = _decryptor.Decrypt(envelope.EncryptedDeliveryContext); } - catch (Exception ex) + catch { - _log.LogError("{Tag} decryption failed: {Reason}", Tag, ex.Message); - return new ObjectResult(new { error = "decryption_failed", correlationId, requestId }) { StatusCode = 400 }; + return Reply(400, new { error = "decryption_failed", correlationId, requestId }); } - var kidMatches = string.IsNullOrEmpty(expectedKeyId) || decrypted.Kid == expectedKeyId; - Log("kid", $"{decrypted.Kid}{(kidMatches ? "" : " (DOES NOT match EPP_ENCRYPTION_KEY_ID)")}"); - Log("alg / enc", $"{decrypted.Alg} / {decrypted.Enc}"); - Log("decrypted", "OK"); + if (!string.IsNullOrEmpty(config.ExpectedKeyId) + && !string.Equals(config.ExpectedKeyId, decrypted.Kid, StringComparison.Ordinal)) + _log.LogWarning("encryption_key_id_mismatch"); var context = decrypted.Context; - Log("nonce", context.Nonce); + if (string.IsNullOrWhiteSpace(context.Nonce) || string.IsNullOrWhiteSpace(context.PhoneNumber) || string.IsNullOrWhiteSpace(context.Message)) + return Reply(400, new { error = "bad_request", reason = "incomplete delivery context", correlationId, requestId }); - if (logPlaintext) - { - // DIAGNOSTICS ONLY — writes the phone number and passcode to the log. - Log("phoneNumber", context.PhoneNumber); - Log("extension", context.Extension ?? "(none)"); - Log("locale", context.Locale); - Log("message", context.Message); - Log("riskContext", context.RiskContext.HasValue ? context.RiskContext.Value.ToString() : "(none)"); - } - else - { - _log.LogInformation("{Tag} plaintext suppressed (EPP_LOG_PLAINTEXT=false)", Tag); - } + // Evaluation proves validation/decryption without requiring any provider configuration. + if (evaluation) + return Reply(200, new { nonce = context.Nonce, correlationId, providerStatus = "accepted" }); - if (string.IsNullOrEmpty(context.Nonce) || string.IsNullOrEmpty(context.PhoneNumber) || string.IsNullOrEmpty(context.Message)) - { - _log.LogError("{Tag} delivery context is incomplete (nonce/phoneNumber/message)", Tag); - return new ObjectResult(new { error = "bad_request", reason = "incomplete delivery context", correlationId, requestId }) { StatusCode = 400 }; - } - - var evaluation = envelope.Mode == EnvelopeParser.ModeEvaluation; var channel = EnvelopeParser.ChannelName(envelope.Channel)!; var dispatch = new DispatchRequest( Destination: context.PhoneNumber!, - Message: channel == "voice" ? SpacePasscodeForVoice(context.Message) : context.Message, + Message: context.Message, Channel: channel, MessageId: clientRequestId, CorrelationId: correlationId, Locale: context.Locale); - // Microsoft allows 3.2 s for the whole call, so the provider is called after the response. - var deliveryCorrelationId = correlationId; - _ = Task.Run(async () => - { - try - { - var result = await _engine.DispatchAsync(dispatch, null, evaluation, requestId, _log); - _log.LogInformation("{Tag} provider result : httpStatus={Status} correlationId={CorrelationId}", - Tag, result.HttpStatus, deliveryCorrelationId); - } - catch (Exception ex) - { - _log.LogError("{Tag} provider delivery failed: {Error}", Tag, ex.Message); - } - }); - - // Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery - // and Microsoft re-sends over its own telephony, so the user gets the code twice. - Log("responding", $"200, nonce echoed, {(DateTimeOffset.UtcNow - started).TotalMilliseconds:F0} ms"); - _log.LogInformation("{Tag} ======== done ========", Tag); - - return new ObjectResult(new { nonce = context.Nonce, correlationId, providerStatus = "accepted" }) - { StatusCode = 200 }; + // A nonce acknowledges delivery, not just decryption. Wait for the bounded provider call. + var result = await _engine.DispatchAsync(dispatch, requestId); + if (result.HttpStatus != 200) + return Reply(result.HttpStatus, new { error = "provider_delivery_failed", correlationId, requestId }); + + return Reply(200, new { nonce = context.Nonce, correlationId, providerStatus = "accepted" }); + } + catch + { + return Reply(500, new { error = "delivery_failed", correlationId, requestId }); } - catch (Exception ex) + finally { - // Verbose on purpose: this endpoint exists to diagnose onboarding. - _log.LogError("{Tag} FAILED after {Elapsed} ms: {Error}", Tag, (DateTimeOffset.UtcNow - started).TotalMilliseconds, ex.Message); - _log.LogInformation("{Tag} ======== failed ========", Tag); - return new ObjectResult(new { error = "delivery_failed", detail = ex.Message, correlationId }) { StatusCode = 500 }; + var correlationHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(correlationId)))[..16].ToLowerInvariant(); + _log.LogInformation("[EPP] RequestId={RequestId} CorrelationId={CorrelationId} HttpStatus={HttpStatus} ElapsedMs={ElapsedMs} Evaluation={Evaluation}", + requestId, correlationHash, httpStatus, started.ElapsedMilliseconds, evaluation); } } } diff --git a/dotnet/Program.cs b/dotnet/Program.cs index f9ae1d2..678961b 100644 --- a/dotnet/Program.cs +++ b/dotnet/Program.cs @@ -4,19 +4,21 @@ using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; var builder = FunctionsApplication.CreateBuilder(args); builder.ConfigureFunctionsWebApplication(); -builder.Services.AddHttpClient(); +// The handler summary is sufficient; provider URLs must not appear in factory logs. +builder.Logging.AddFilter("System.Net.Http.HttpClient." + DispatchEngine.ProviderHttpClientName, LogLevel.None); +builder.Services.AddHttpClient(DispatchEngine.ProviderHttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -// Provider adapters — add one line to onboard a provider. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/dotnet/README.md b/dotnet/README.md index 9c4c6d2..1ac8039 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1,45 +1,61 @@ # External Phone Provider Function — C# (.NET isolated worker) -A C# implementation of the External Phone Provider OTP-delivery Function, conforming to the shared -[contract](../docs/CONTRACT.md). Same design as the [`javascript/`](../javascript/) version: -one dispatch engine + drop-in provider adapters, env-provisioned config, secrets in Key Vault. - -## Layout - -``` -dotnet/ -├─ Program.cs # host + DI registration (add one line to onboard a provider) -├─ Functions/SendOtp.cs # HTTP trigger: POST /api/SendOtp -├─ Src/ -│ ├─ DispatchEngine.cs # envelope parse → JWE decrypt → provider dispatch -│ ├─ ProviderRegistry.cs # keyed adapter registry + EPP_PROVIDER_NAME resolution -│ ├─ IProviderAdapter.cs # Manifest + BuildRequest + ParseResponse -│ ├─ Providers/*.cs # infobip, telesign, soprano, sinch -│ ├─ SecretResolver.cs # Key Vault via managed identity (cached) -│ ├─ ISecretResolver.cs # secret-resolver abstraction (injectable for tests) -│ ├─ OutcomeMapper.cs # status → outcome → HTTP status -│ ├─ Models.cs # DispatchRequest + shared records -│ └─ TokenValidator.cs # Entra JWT validation when EPP_REQUIRE_AUTH=true -└─ tests/ # xUnit conformance tests -``` - -## Build, test, run - -```bash -cd dotnet -dotnet build # build the Functions app -dotnet test tests # run conformance tests -func start # run locally (copy ../docs/local.settings.sample.json to local.settings.json) -``` - -## Deploy - -```bash -func azure functionapp publish --dotnet-isolated -``` - -The app's **managed identity** needs the **Key Vault Secrets User** role on the vault. Configuration -(env var names, Key Vault secret names, behaviors) is identical to the contract — see -[`../docs/CONTRACT.md`](../docs/CONTRACT.md). - -Target: .NET 8 isolated worker, Functions v4. +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. + +## Setup and deployment + +1. Follow [customer onboarding](../docs/ONBOARDING.md). Set `EPP_PROVIDER_NAME` to the selected + adapter's registered manifest id (`` is only a placeholder). +2. Consult the selected adapter and its manifest in [Src/Providers/](Src/Providers/) for required + credentials and options. Store credentials in Key Vault under the declared secret names, grant + the Function's managed identity *Key Vault Secrets User*, and configure the matching endpoint/options. +3. Base private local settings on [../docs/local.settings.sample.json](../docs/local.settings.sample.json), + replacing placeholders and selecting `FUNCTIONS_WORKER_RUNTIME=dotnet-isolated`. Put settings + at the app root beside [host.json](host.json). Configure decryption from the + [shared catalog](../docs/CONTRACT.md#4-configuration-app-settings--env) and caller trust through + [Easy Auth](../docs/ONBOARDING.md#2-provision-encryption-and-deployment-trust), the only authentication + gate before the anonymous Function. Enable `requireAuthentication=true`, + `unauthenticatedClientAction=Return401` and `requireHttps=true`; pin the trusted tenant issuer, + endpoint-app `allowedAudiences` and a nonempty `allowedApplications` list for the authorized SAS + caller. Do not exclude SendOtp. There is no backup application token validation; never expose the + endpoint to the public internet with Easy Auth disabled or bypassed. +4. Build [dotnet.csproj](dotnet.csproj), run the offline xUnit suites in + [tests/Epp.Otp.Tests.csproj](tests/Epp.Otp.Tests.csproj), and start the local Functions host from this folder. + Core Tools has no Easy Auth: bind only to loopback, with no tunnels or public forwarding. +5. Publish this app folder to a compatible .NET isolated Function App. Inspect the package: both + [.funcignore](.funcignore) and the project protect local settings; private keys must not be included. + Offline tests cover application behavior, not platform authentication; run the separate + [deployed security checks](../docs/ONBOARDING.md#4-package-deploy-and-verify). + +## Request behavior + +`POST /api/SendOtp` uses the same request and trust boundaries as the other runtimes. Incoming +`mode`, `channel`, `ttlSeconds` and `tenantId` are request data, not deployment authentication settings. +Easy Auth authenticates and authorizes the caller before the anonymous handler validates the envelope +and decrypts the JWE. Incoming `Authorization` is not parsed or echoed by the handler. JWE does not +authenticate SAS: anyone with the public key can encrypt a request, and a fixed nonce is not authentication. + +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. +Platform/key prerequisites and HTTP outcomes are defined in the +[contract](../docs/CONTRACT.md#evaluation-generic-shutter). + +## Source + +| Source | Purpose | +|---|---| +| [Program.cs](Program.cs) | Host and adapter registration | +| [Functions/SendOtp.cs](Functions/SendOtp.cs) | HTTP handler | +| [Src/AppConfig.cs](Src/AppConfig.cs) | Shared deployment settings | +| [Src/DispatchEngine.cs](Src/DispatchEngine.cs) | Envelope/JWE handling and dispatch | +| [Src/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/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. diff --git a/dotnet/Src/AppConfig.cs b/dotnet/Src/AppConfig.cs new file mode 100644 index 0000000..8c02454 --- /dev/null +++ b/dotnet/Src/AppConfig.cs @@ -0,0 +1,20 @@ +namespace Epp.Otp; + +public sealed class AppConfig +{ + public string? DecryptionKeyPem { get; init; } + public string? ExpectedKeyId { get; init; } + public string? ProviderName { get; init; } + public string? ProviderEndpoint { get; init; } + // Keep the raw value; DispatchEngine owns timeout normalization. + public string? ProviderTimeoutMs { get; init; } + + public static AppConfig Read(IEnv env) => new() + { + DecryptionKeyPem = env.Get("EPP_DECRYPTION_KEY_PEM"), + ExpectedKeyId = env.Get("EPP_ENCRYPTION_KEY_ID"), + ProviderName = env.Get("EPP_PROVIDER_NAME")?.Trim().ToLowerInvariant(), + ProviderEndpoint = env.Get("EPP_PROVIDER_ENDPOINT"), + 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 ea1d6a5..2633355 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -2,13 +2,9 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; namespace Epp.Otp; -// Delivery pipeline: parse the cleartext SAS envelope, decrypt the JWE that carries the PII, then -// dispatch to the configured provider. Fail-closed — only a Continue outcome is "accepted". - public sealed record Envelope( string? Type, string? TenantId, @@ -20,6 +16,7 @@ public sealed record Envelope( public static class EnvelopeParser { + public const string EnvelopeType = "microsoft.mfa.otpDeliver.v1"; public const int ModeLive = 1; public const int ModeEvaluation = 2; @@ -54,8 +51,11 @@ public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) return name is not null && ModeByName.TryGetValue(name, out var mapped) ? mapped : null; } + if (String("type") != EnvelopeType) + return (null, "unsupported envelope type"); + var encrypted = String("encryptedDeliveryContext"); - if (string.IsNullOrEmpty(encrypted)) + if (string.IsNullOrWhiteSpace(encrypted)) return (null, "encryptedDeliveryContext is required"); var channel = Channel(); @@ -66,12 +66,21 @@ public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) if (mode is null) return (null, "unsupported mode"); + int? ttlSeconds = null; + if (payload.TryGetProperty("ttlSeconds", out var ttl)) + { + if (ttl.ValueKind != JsonValueKind.Number || !ttl.TryGetInt32(out var seconds)) + return (null, "invalid ttlSeconds"); + if (seconds <= 0) + return (null, "ttlSeconds expired"); + ttlSeconds = seconds; + } + return (new Envelope(String("type"), String("tenantId"), String("correlationId"), - channel.Value, mode.Value, Int("ttlSeconds"), encrypted), null); + channel.Value, mode.Value, ttlSeconds, encrypted), null); } } -// Decrypted JWE plaintext: phone + the rendered message, which includes the passcode. public sealed class DeliveryContext { [JsonPropertyName("nonce")] public string? Nonce { get; set; } @@ -84,7 +93,6 @@ public sealed class DeliveryContext public sealed record JweResult(string? Kid, string? Alg, string? Enc, DeliveryContext Context); -// Injectable so tests use a local key. public interface IJweKeyProvider { RSA GetPrivateKey(string? kid); @@ -124,7 +132,6 @@ private static void AssertWellFormed(string compactJwe) } } -// Imported once: a per-delivery RSA import would sit inside the response budget. public sealed class EnvJweKeyProvider : IJweKeyProvider { private readonly IEnv _env; @@ -135,7 +142,7 @@ public sealed class EnvJweKeyProvider : IJweKeyProvider public RSA GetPrivateKey(string? kid) { - var pem = _env.Get("EPP_DECRYPTION_KEY_PEM"); + var pem = AppConfig.Read(_env).DecryptionKeyPem; if (string.IsNullOrEmpty(pem)) throw new InvalidOperationException("private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)"); @@ -148,8 +155,7 @@ public RSA GetPrivateKey(string? kid) return rsa; } - // The setup script stores the key as base64 over the PEM so its newlines survive being carried as - // an app setting, so accept either form. + // Base64 preserves PEM newlines in app settings; accept either form. private static string NormalizePem(string value) => value.Contains("-----BEGIN", StringComparison.Ordinal) ? value @@ -158,7 +164,9 @@ private static string NormalizePem(string value) => public sealed class DispatchEngine { + public const string ProviderHttpClientName = "otp-provider"; private const int DefaultTimeoutMs = 1500; + private const int MaxTimeoutMs = 2500; private readonly ProviderRegistry _registry; private readonly ISecretResolver _secrets; private readonly IHttpClientFactory _httpFactory; @@ -172,105 +180,109 @@ public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpC _env = env ?? new ProcessEnv(); } - public async Task DispatchAsync(DispatchRequest dispatch, string? requestProvider, bool shutter, string requestId, ILogger log) + public async Task DispatchAsync(DispatchRequest dispatch, string requestId) { - var adapter = _registry.Resolve(requestProvider); + var config = AppConfig.Read(_env); + var adapter = _registry.Get(config.ProviderName); if (adapter is null) - { - log.LogWarning("[DISPATCH_ERROR] requestId={RequestId} unknown provider={Provider}", requestId, requestProvider ?? "n/a"); return new DispatchResult(400, new { status = "error", reason = "unknown provider", requestId }); - } var manifest = adapter.Manifest; var providerId = manifest.Id; var channel = (dispatch.Channel ?? "sms").ToLowerInvariant(); if (!OutcomeMapper.DefaultChannels.Contains(channel)) - return new DispatchResult(400, new { status = "error", provider = providerId, reason = $"channel '{channel}' not supported", requestId }); + 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)); - // Credential (fail closed 502 if missing) — this is our credential, not the caller's token. - ProviderCredential? credential = null; + ProviderCredential credential; try { credential = await ResolveCredentialAsync(manifest.Auth); } - catch (Exception ex) { log.LogError("[DISPATCH_ERROR] requestId={RequestId} provider={Provider} credential error={Error}", requestId, providerId, ex.Message); } + catch { return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); } - var identityRequired = credential is { Mode: "apiKey" } && !string.IsNullOrEmpty(manifest.Auth.IdentityKeyVaultSecretName); - var credentialUnavailable = credential is null - || (credential.Mode == "oauth2" && string.IsNullOrEmpty(credential.Token)) - || (credential.Mode == "apiKey" && string.IsNullOrEmpty(credential.Secret)) + 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 endpoint = ResolveEndpoint(manifest, _env); - if (string.IsNullOrEmpty(endpoint)) - return new DispatchResult(502, FailBody(providerId, channel, "provider endpoint not configured", dispatch, requestId)); - - var req = adapter.BuildRequest(channel, endpoint, dispatch, credential!, _env); - log.LogInformation("[DISPATCH] requestId={RequestId} provider={Provider} channel={Channel} shutter={Shutter}", requestId, providerId, channel, shutter); + var endpoint = config.ProviderEndpoint; + if (!IsHttpsEndpoint(endpoint)) + return new DispatchResult(502, FailBody(providerId, channel, "provider endpoint invalid or not configured", dispatch, requestId)); - if (shutter) - return new DispatchResult(200, new { status = "accepted", shutterProcessed = true, provider = providerId, channel, correlationId = dispatch.CorrelationId, messageId = dispatch.MessageId, requestId }); - - var timeoutMs = int.TryParse(_env.Get("EPP_PROVIDER_TIMEOUT_MS"), out var parsedTimeout) ? parsedTimeout : DefaultTimeoutMs; - HttpResponseMessage resp; - string body; + var timeoutMs = NormalizeProviderTimeoutMs(config.ProviderTimeoutMs); try { - (resp, body) = await SendAsync(req, timeoutMs); + var req = adapter.BuildRequest(channel, endpoint!, dispatch, credential, _env); + if (!IsHttpsEndpoint(req.Url)) + return new DispatchResult(502, FailBody(providerId, channel, "provider request endpoint invalid", dispatch, requestId)); + + 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(); } + + var parsed = adapter.ParseResponse(providerHttpStatus, success, json); + var outcome = OutcomeMapper.ResolveOutcome(manifest, parsed); + var httpStatus = OutcomeMapper.ToHttpStatus(outcome, parsed.ProviderHttpStatus); + + return new DispatchResult(httpStatus, new + { + status = outcome == Outcome.Continue ? "accepted" : "failed", + outcome = outcome.ToString(), + provider = providerId, + channel, + messageId = dispatch.MessageId, + correlationId = dispatch.CorrelationId, + requestId, + }); } catch (OperationCanceledException) { - log.LogWarning("[DISPATCH_TIMEOUT] requestId={RequestId} provider={Provider}", requestId, providerId); return new DispatchResult(504, FailBody(providerId, channel, $"endpoint timeout after {timeoutMs}ms", dispatch, requestId)); } - catch (Exception ex) + catch { - log.LogError("[DISPATCH_ERROR] requestId={RequestId} provider={Provider} reason={Reason}", requestId, providerId, ex.Message); - return new DispatchResult(502, FailBody(providerId, channel, ex.Message, dispatch, requestId)); + return new DispatchResult(502, FailBody(providerId, channel, "provider request failed", dispatch, requestId)); } - - 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(); } - - var parsed = adapter.ParseResponse((int)resp.StatusCode, resp.IsSuccessStatusCode, json); - var outcome = OutcomeMapper.ResolveOutcome(manifest, parsed); - var httpStatus = OutcomeMapper.ToHttpStatus(outcome, parsed.ProviderHttpStatus); - - log.LogInformation("[DISPATCH_RESULT] requestId={RequestId} provider={Provider} channel={Channel} outcome={Outcome} providerStatus={Status} httpStatus={Http}", - requestId, providerId, channel, outcome, parsed.ProviderStatusName ?? parsed.ProviderStatusCode ?? "n/a", httpStatus); - - return new DispatchResult(httpStatus, new - { - status = outcome == Outcome.Continue ? "accepted" : "failed", - outcome = outcome.ToString(), - provider = providerId, - channel, - messageId = dispatch.MessageId, - correlationId = dispatch.CorrelationId, - providerMessageId = parsed.ProviderMessageId, - providerStatus = parsed.ProviderStatusName ?? parsed.ProviderStatusCode, - providerStatusDescription = parsed.ProviderStatusDescription, - requestId, - }); } private async Task ResolveCredentialAsync(AuthConfig auth) { - if (auth.Mode == "oauth2") return new ProviderCredential("oauth2", Token: null); // not wired -> fails closed 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); } - // Base URL from app settings: one provider is active per deployment, so the endpoint is a single - // EPP_PROVIDER_ENDPOINT rather than a per-provider key. - private static string? ResolveEndpoint(ProviderManifest manifest, IEnv env) => env.Get("EPP_PROVIDER_ENDPOINT"); + internal static int NormalizeProviderTimeoutMs(string? value) + { + var text = value?.Trim(); + if (string.IsNullOrEmpty(text)) return DefaultTimeoutMs; + + // Saturate while scanning every character: arbitrarily large decimal values are valid, + // but signs, exponents, hex, non-ASCII digits and invalid suffixes are not. + var timeout = 0; + foreach (var digit in text) + { + if (digit < '0' || digit > '9') return DefaultTimeoutMs; + timeout = Math.Min(MaxTimeoutMs, timeout * 10 + digit - '0'); + } + return timeout > 0 ? timeout : DefaultTimeoutMs; + } + + internal static bool IsHttpsEndpoint(string? endpoint) => + Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttps + && !string.IsNullOrEmpty(uri.Host) + && uri.Port > 0 + && string.IsNullOrEmpty(uri.UserInfo) + && string.IsNullOrEmpty(uri.Fragment); - private async Task<(HttpResponseMessage, string)> SendAsync(ProviderHttpRequest req, int timeoutMs) + private async Task<(int HttpStatus, bool Success, string Body)> SendAsync(ProviderHttpRequest req, int timeoutMs) { using var cts = new CancellationTokenSource(timeoutMs); - var client = _httpFactory.CreateClient(); + using var client = _httpFactory.CreateClient(ProviderHttpClientName); using var message = new HttpRequestMessage(new HttpMethod(req.Method), req.Url) { Content = new StringContent(req.Body, Encoding.UTF8, req.Headers.TryGetValue("Content-Type", out var ct) ? ct : "application/json"), @@ -280,9 +292,11 @@ private async Task ResolveCredentialAsync(AuthConfig auth) if (k.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) continue; if (!message.Headers.TryAddWithoutValidation(k, v)) message.Content.Headers.TryAddWithoutValidation(k, v); } - var resp = await client.SendAsync(message, cts.Token); - var body = await resp.Content.ReadAsStringAsync(cts.Token); - return (resp, body); + using var resp = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cts.Token); + using var stream = await resp.Content.ReadAsStreamAsync(cts.Token); + using var reader = new StreamReader(stream, Encoding.UTF8); + var text = await reader.ReadToEndAsync(cts.Token); + return ((int)resp.StatusCode, resp.IsSuccessStatusCode, text); } private static object FailBody(string provider, string channel, string reason, DispatchRequest d, string requestId) => diff --git a/dotnet/Src/IProviderAdapter.cs b/dotnet/Src/IProviderAdapter.cs index 5d048f2..1d4a7dc 100644 --- a/dotnet/Src/IProviderAdapter.cs +++ b/dotnet/Src/IProviderAdapter.cs @@ -2,7 +2,6 @@ namespace Epp.Otp; -// A provider is one adapter: manifest (protocol facts) + build/parse. Onboarding = add one class. public interface IProviderAdapter { ProviderManifest Manifest { get; } diff --git a/dotnet/Src/ISecretResolver.cs b/dotnet/Src/ISecretResolver.cs index eab5801..5b75ad9 100644 --- a/dotnet/Src/ISecretResolver.cs +++ b/dotnet/Src/ISecretResolver.cs @@ -1,6 +1,5 @@ namespace Epp.Otp; -// Seam over Key Vault so the engine can be unit-tested with a fake. public interface ISecretResolver { Task ResolveAsync(string? secretName); diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index 6f7ed7a..54d5d84 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -2,8 +2,6 @@ namespace Epp.Otp; -// Language-agnostic contract types (see /docs/CONTRACT.md). - public enum Outcome { Continue, Fail, Block, StepUp } public sealed record DispatchRequest( @@ -14,7 +12,7 @@ public sealed record DispatchRequest( string? CorrelationId, string? Locale); -public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, string? Token = null); +public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null); public sealed record ProviderHttpRequest(string Url, string Method, Dictionary Headers, string Body); @@ -32,7 +30,6 @@ public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDicti public sealed record DispatchResult(int HttpStatus, object Body); -// The env snapshot passed to adapters. public interface IEnv { string? Get(string key); } public sealed class ProcessEnv : IEnv diff --git a/dotnet/Src/OutcomeMapper.cs b/dotnet/Src/OutcomeMapper.cs index 174501d..95c322d 100644 --- a/dotnet/Src/OutcomeMapper.cs +++ b/dotnet/Src/OutcomeMapper.cs @@ -8,13 +8,19 @@ public static class OutcomeMapper public static Outcome ResolveOutcome(ProviderManifest manifest, ParsedResponse parsed) { var key = parsed.ProviderStatusName ?? parsed.ProviderStatusCode; + Outcome outcome; if (!string.IsNullOrEmpty(key)) { - if (manifest.ResponseMapping.TryGetValue(key, out var mapped)) return mapped; - return manifest.ResponseMapping.TryGetValue("default", out var defaultOutcome) ? defaultOutcome : Outcome.Fail; + outcome = manifest.ResponseMapping.TryGetValue(key, out var mapped) ? mapped + : manifest.ResponseMapping.TryGetValue("default", out var defaultOutcome) ? defaultOutcome : Outcome.Fail; } - if (parsed.Success) return Outcome.Continue; - return manifest.ResponseMapping.TryGetValue("default", out var fallbackOutcome) ? fallbackOutcome : Outcome.Fail; + else + { + outcome = parsed.Success ? Outcome.Continue + : manifest.ResponseMapping.TryGetValue("default", out var fallbackOutcome) ? fallbackOutcome : Outcome.Fail; + } + // A success-shaped body cannot turn a failed HTTP request into an acknowledgement. + return outcome == Outcome.Continue && !parsed.Success ? Outcome.Fail : outcome; } public static int ToHttpStatus(Outcome outcome, int providerHttpStatus) => outcome switch diff --git a/dotnet/Src/ProviderRegistry.cs b/dotnet/Src/ProviderRegistry.cs index f6fc314..0af6b71 100644 --- a/dotnet/Src/ProviderRegistry.cs +++ b/dotnet/Src/ProviderRegistry.cs @@ -1,15 +1,12 @@ namespace Epp.Otp; -// One provider is active per deployment; requestProvider is a test override. public sealed class ProviderRegistry { private readonly IReadOnlyDictionary _byId; - private readonly IEnv _env; - public ProviderRegistry(IEnumerable adapters, IEnv? env = null) + public ProviderRegistry(IEnumerable adapters) { _byId = adapters.ToDictionary(a => a.Manifest.Id.ToLowerInvariant(), a => a); - _env = env ?? new ProcessEnv(); } public IProviderAdapter? Get(string? id) @@ -17,12 +14,4 @@ public ProviderRegistry(IEnumerable adapters, IEnv? env = null if (string.IsNullOrWhiteSpace(id)) return null; return _byId.TryGetValue(id.ToLowerInvariant(), out var adapter) ? adapter : null; } - - public IProviderAdapter? Resolve(string? requestProvider) - { - var id = !string.IsNullOrWhiteSpace(requestProvider) - ? requestProvider - : _env.Get("EPP_PROVIDER_NAME"); - return Get(id); - } } diff --git a/dotnet/Src/Providers/InfobipProvider.cs b/dotnet/Src/Providers/InfobipProvider.cs index e068a1a..3fd5207 100644 --- a/dotnet/Src/Providers/InfobipProvider.cs +++ b/dotnet/Src/Providers/InfobipProvider.cs @@ -2,7 +2,6 @@ namespace Epp.Otp.Providers; -// Infobip: SMS via /sms/3/messages, voice via /tts/3/advanced. Auth: App API key. public sealed class InfobipProvider : IProviderAdapter { public ProviderManifest Manifest { get; } = new( @@ -22,10 +21,9 @@ public sealed class InfobipProvider : IProviderAdapter public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { var senderId = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? "Verify"; - var auth = credential.Mode == "oauth2" ? $"Bearer {credential.Token}" : $"App {credential.Secret}"; var headers = new Dictionary { - ["Authorization"] = auth, + ["Authorization"] = $"App {credential.Secret}", ["Content-Type"] = "application/json", ["Accept"] = "application/json", }; diff --git a/dotnet/Src/Providers/SinchProvider.cs b/dotnet/Src/Providers/SinchProvider.cs index 9e9376a..673b42f 100644 --- a/dotnet/Src/Providers/SinchProvider.cs +++ b/dotnet/Src/Providers/SinchProvider.cs @@ -2,7 +2,6 @@ namespace Epp.Otp.Providers; -// Sinch: SMS via XMS Batches (POST /xms/v1/{plan}/batches, Bearer). Voice via Calling TTS callout. public sealed class SinchProvider : IProviderAdapter { public ProviderManifest Manifest { get; } = new( @@ -20,10 +19,9 @@ public sealed class SinchProvider : IProviderAdapter public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { - var bearer = credential.Mode == "oauth2" ? credential.Token : credential.Secret; var headers = new Dictionary { - ["Authorization"] = $"Bearer {bearer}", + ["Authorization"] = $"Bearer {credential.Secret}", ["Content-Type"] = "application/json", ["Accept"] = "application/json", }; diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index 537789e..7ffb5df 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -2,7 +2,6 @@ namespace Epp.Otp.Providers; -// Soprano Connect (MEMS): POST {base}/messages/{sms|voice}. Auth: X-MEMS-API-ID + X-MEMS-API-Key. public sealed class SopranoProvider : IProviderAdapter { public ProviderManifest Manifest { get; } = new( @@ -18,83 +17,45 @@ public sealed class SopranoProvider : IProviderAdapter ["QUEUED"] = Outcome.Continue, ["FAILED"] = Outcome.Fail, ["REJECTED"] = Outcome.Fail, + ["FILTERED"] = Outcome.Fail, ["BLOCKED"] = Outcome.Block, ["default"] = Outcome.Fail, }); public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { - var messageType = channel == "voice" ? "voice" : "sms"; - var headers = new Dictionary { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }; - if (credential.Mode == "oauth2") headers["Authorization"] = $"Bearer {credential.Token}"; - else { headers["X-MEMS-API-ID"] = credential.Identity ?? string.Empty; headers["X-MEMS-API-Key"] = credential.Secret ?? string.Empty; } - - // Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A - // non-numeric account name is sent as a free-text source instead. - object endpoints_or_source() + var headers = new Dictionary { - var account = env.Get("EPP_PROVIDER_ACCOUNT_NAME"); - if (!string.IsNullOrEmpty(account) && int.TryParse(account, out var sourceId)) - return new { endpoints = new[] { new { type = int.TryParse(env.Get("SOPRANO_SOURCE_TYPE"), out var parsedSourceType) ? parsedSourceType : 1, id = sourceId } } }; - return new { source = account }; - } - - var clientRef = dispatch.CorrelationId ?? dispatch.MessageId; - object body; - if (messageType == "voice") - { - var voiceLanguage = env.Get("SOPRANO_VOICE_LANGUAGE") ?? ((dispatch.Locale?.Contains('-') ?? false) ? dispatch.Locale! : "en-US"); - body = Merge(endpoints_or_source(), new - { - messageType, - destination = dispatch.Destination, - clientReference = clientRef, - voice = new - { - text2voice = new - { - beforePasswordText = dispatch.Message ?? string.Empty, - password = string.Empty, - afterPasswordText = string.Empty, - language = voiceLanguage, - gender = int.TryParse(env.Get("SOPRANO_VOICE_GENDER"), out var parsedGender) ? parsedGender : 1, - loop = 1, - }, - }, - }); - } - else + ["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 { - body = Merge(endpoints_or_source(), new { messageType, destination = dispatch.Destination, text = dispatch.Message, clientReference = clientRef }); - } - - return new ProviderHttpRequest($"{endpoint}/messages/{messageType}", "POST", headers, JsonSerializer.Serialize(body)); + text = dispatch.Message, + destination = dispatch.Destination.TrimStart('+'), + messageTypes = new[] { channel == "voice" ? "voice" : "sms" }, + correlationId = dispatch.CorrelationId ?? dispatch.MessageId, + shutterMode = false, + }; + + return new ProviderHttpRequest($"{endpoint.TrimEnd('/')}/messages/omnimsg", "POST", headers, JsonSerializer.Serialize(body)); } public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) { var payload = json.ValueKind == JsonValueKind.Array && json.GetArrayLength() > 0 ? json[0] : json; - string? id = null, status = null, desc = null; + string? id = null, status = null; if (payload.ValueKind == JsonValueKind.Object) { if (payload.TryGetProperty("id", out var idElement)) id = idElement.ToString(); else if (payload.TryGetProperty("messageId", out var messageIdElement)) id = messageIdElement.ToString(); - if (payload.TryGetProperty("status", out var statusElement)) status = statusElement.GetString()?.ToUpperInvariant(); - else if (payload.TryGetProperty("state", out var stateElement)) status = stateElement.GetString()?.ToUpperInvariant(); - if (payload.TryGetProperty("errorDescription", out var errorElement)) desc = errorElement.GetString(); - else if (payload.TryGetProperty("statusText", out var statusTextElement)) desc = statusTextElement.GetString(); - else if (payload.TryGetProperty("description", out var descriptionElement)) desc = descriptionElement.GetString(); + if (!payload.TryGetProperty("status", out var statusElement) || statusElement.ValueKind == JsonValueKind.Null) + payload.TryGetProperty("state", out statusElement); + if (statusElement.ValueKind == JsonValueKind.String) status = statusElement.GetString(); } - status ??= ok ? "SUBMITTED" : null; - return new ParsedResponse(ok, httpStatus, id, status, null, desc); - } - - // Shallow-merge two anonymous objects into a dictionary for JSON serialization. - private static Dictionary Merge(object first, object second) - { - var merged = new Dictionary(); - foreach (var property in first.GetType().GetProperties()) merged[property.Name] = property.GetValue(first); - foreach (var property in second.GetType().GetProperties()) merged[property.Name] = property.GetValue(second); - return merged; + status = string.IsNullOrWhiteSpace(status) ? "UNKNOWN" : status.ToUpperInvariant(); + return new ParsedResponse(ok, httpStatus, id, status); } } diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs index 1f6f7a2..79978ad 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -3,7 +3,6 @@ namespace Epp.Otp.Providers; -// Telesign: SMS via /v1/messaging, voice via /v1/voice (form-urlencoded). Auth: HTTP Basic (customer_id:api_key). public sealed class TelesignProvider : IProviderAdapter { public ProviderManifest Manifest { get; } = new( @@ -25,9 +24,7 @@ public sealed class TelesignProvider : IProviderAdapter public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { - var authorization = credential.Mode == "oauth2" - ? $"Bearer {credential.Token}" - : "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.Identity}:{credential.Secret}")); + var authorization = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.Identity}:{credential.Secret}")); var externalId = dispatch.CorrelationId ?? dispatch.MessageId; var form = new Dictionary(); diff --git a/dotnet/Src/TokenValidator.cs b/dotnet/Src/TokenValidator.cs deleted file mode 100644 index a8f43c5..0000000 --- a/dotnet/Src/TokenValidator.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.IdentityModel.Protocols; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; - -namespace Epp.Otp; - -// Validates the Entra JWT when EPP_REQUIRE_AUTH=true (aud/issuer/JWKS, RS256). No-op pass-through -// otherwise — Easy Auth is the primary gate; this is the backstop. -public sealed class TokenValidator -{ - private readonly JwtSecurityTokenHandler _handler = new(); - private readonly IEnv _env; - private ConfigurationManager? _configManager; - - public TokenValidator(IEnv? env = null) => _env = env ?? new ProcessEnv(); - - // azp is the v2 caller claim, appid the v1 one. - public static bool IsExpectedCaller(string? callerAppId, string? expectedClientId) => - string.IsNullOrEmpty(expectedClientId) - || string.Equals(callerAppId, expectedClientId, StringComparison.OrdinalIgnoreCase); - - public sealed record Result(bool Ok, string? Reason = null, string? CallerObjectId = null); - - public async Task ValidateAsync(string? authorizationHeader) - { - if (!string.Equals(_env.Get("EPP_REQUIRE_AUTH"), "true", StringComparison.OrdinalIgnoreCase)) - return new Result(true); - - var audience = _env.Get("EPP_EXPECTED_AUDIENCE"); - var tenantId = _env.Get("EPP_TENANT_ID"); - if (string.IsNullOrEmpty(audience) || string.IsNullOrEmpty(tenantId)) - return new Result(false, "auth misconfigured"); - - if (string.IsNullOrEmpty(authorizationHeader) || !authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return new Result(false, "missing bearer token"); - - var token = authorizationHeader["Bearer ".Length..].Trim(); - var authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; - _configManager ??= new ConfigurationManager( - $"{authority}/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever()); - - try - { - var config = await _configManager.GetConfigurationAsync(); - // EPP_EXPECTED_ISSUER pins one issuer; otherwise accept both the v2 and v1 forms. - var pinnedIssuer = _env.Get("EPP_EXPECTED_ISSUER"); - var validIssuers = string.IsNullOrEmpty(pinnedIssuer) - ? new[] { $"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/" } - : new[] { pinnedIssuer }; - var parameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuers = validIssuers, - ValidateAudience = true, - ValidAudience = audience, - ValidateLifetime = true, - IssuerSigningKeys = config.SigningKeys, - ValidateIssuerSigningKey = true, - }; - var principal = _handler.ValidateToken(token, parameters, out _); - - var callerAppId = principal.FindFirst("azp")?.Value ?? principal.FindFirst("appid")?.Value; - if (!IsExpectedCaller(callerAppId, _env.Get("EPP_EXPECTED_CLIENT_ID"))) - return new Result(false, "unexpected caller"); - - var oid = principal.FindFirst("oid")?.Value ?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value; - return new Result(true, CallerObjectId: oid); - } - catch - { - return new Result(false, "token validation failed"); - } - } -} diff --git a/dotnet/dotnet.csproj b/dotnet/dotnet.csproj index dedc248..0ce9ce4 100644 --- a/dotnet/dotnet.csproj +++ b/dotnet/dotnet.csproj @@ -12,6 +12,7 @@ + @@ -24,8 +25,6 @@ - - diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 8447a47..e19bdeb 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -1,89 +1,90 @@ +using System.Text; using System.Text.Json; -using Epp.Otp; using Epp.Otp.Providers; using Xunit; namespace Epp.Otp.Tests; -// Conformance tests for the pure contract logic (see /docs/CONTRACT.md §6). public class ContractTests { - private sealed class FakeEnv : Dictionary, IEnv - { - public string? Get(string key) => TryGetValue(key, out var v) ? v : null; - } - - private static DispatchRequest Disp(string channel = "sms", string? message = null) => - new("+15551234567", message, channel, "m", "c", null); + private static DispatchRequest Request(string channel = "sms") => + new("+15551234567", " Your code is 918273.\nDo not share. ", channel, "message-id", "correlation-id", "en-US"); - // Easy Auth normally rejects the wrong caller at the platform; these cover the standalone path. [Theory] - [InlineData("anything", "", true)] // unpinned client id accepts any caller - [InlineData("expected-app", "expected-app", true)] - [InlineData("EXPECTED-APP", "expected-app", true)] // Entra ids are case-insensitive - [InlineData("some-other-app", "expected-app", false)] - [InlineData(null, "expected-app", false)] // token carrying no caller claim - public void CallerIsCheckedAgainstExpectedClientId(string? callerAppId, string expected, bool allowed) => - Assert.Equal(allowed, TokenValidator.IsExpectedCaller(callerAppId, expected)); - - [Fact] - public void TokenValidationIsSkippedUnlessRequireAuthIsTrue() + [InlineData("sms")] + [InlineData("voice")] + public void SopranoUsesExactOmnimsgContract(string channel) { - var env = new FakeEnv { ["EPP_REQUIRE_AUTH"] = "false" }; - Assert.True(new TokenValidator(env).ValidateAsync("Bearer whatever").Result.Ok); + var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", Request(channel), + new ProviderCredential("apiKey", "test-key", "test-id"), new TestEnv()); + Assert.Equal("https://provider.example/cgpapi/messages/omnimsg", request.Url); + Assert.Equal("POST", request.Method); + Assert.Equal(4, request.Headers.Count); + Assert.Equal("test-id", request.Headers["X-MEMS-API-ID"]); + 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 + { + text = Request().Message, + destination = "15551234567", + messageTypes = new[] { channel }, + correlationId = "correlation-id", + shutterMode = false, + }; + Assert.Equal(JsonSerializer.Serialize(expected), request.Body); } [Fact] - public void OutcomeMappingAndHttpStatus() + public void SopranoRequiresAnExplicitAcceptedStatus() { - var m = new InfobipProvider().Manifest; - Assert.Equal(Outcome.Continue, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusName: "DELIVERED"))); - // Unknown status fails closed even on HTTP 200. - Assert.Equal(Outcome.Fail, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusName: "WATWAT"))); - Assert.Equal(200, OutcomeMapper.ToHttpStatus(Outcome.Continue, 200)); - Assert.Equal(403, OutcomeMapper.ToHttpStatus(Outcome.Block, 200)); - Assert.Equal(409, OutcomeMapper.ToHttpStatus(Outcome.StepUp, 200)); - Assert.Equal(429, OutcomeMapper.ToHttpStatus(Outcome.Fail, 429)); - Assert.Equal(401, OutcomeMapper.ToHttpStatus(Outcome.Fail, 403)); - Assert.Equal(400, OutcomeMapper.ToHttpStatus(Outcome.Fail, 422)); - Assert.Equal(502, OutcomeMapper.ToHttpStatus(Outcome.Fail, 500)); + var adapter = new SopranoProvider(); + Outcome Parse(string body) + { + using var json = JsonDocument.Parse(body); + return OutcomeMapper.ResolveOutcome(adapter.Manifest, adapter.ParseResponse(200, true, json.RootElement)); + } + Assert.Equal(Outcome.Continue, Parse("[{\"id\":12,\"state\":\"enroute\"}]")); + Assert.Equal(Outcome.Fail, Parse("{\"status\":\"FILTERED\"}")); + Assert.Equal(Outcome.Fail, Parse("{\"status\":\"unknown\"}")); + Assert.Equal(Outcome.Fail, Parse("{\"status\":123,\"state\":\"ACCEPTED\"}")); + Assert.Equal(Outcome.Fail, Parse("{\"status\":false,\"state\":\"ACCEPTED\"}")); } [Fact] - public void InfobipBuildsHttpsSmsRequestWithAppAuthAndCode() + public void OtherProvidersKeepTheirStaticAuthenticationAndProtocols() { - var env = new FakeEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "EPP" }; - var req = new InfobipProvider().BuildRequest("sms", "https://api.infobip.com", - Disp(message: "Use verification code 918273 for Microsoft authentication."), - new ProviderCredential("apiKey", Secret: "ib"), env); + var env = new TestEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "Verify" }; + var credential = new ProviderCredential("apiKey", "test-key", "test-id"); + var sms = new InfobipProvider().BuildRequest("sms", "https://provider.example", Request(), credential, env); + Assert.Equal("App test-key", sms.Headers["Authorization"]); + Assert.EndsWith("/sms/3/messages", sms.Url); + using var smsJson = JsonDocument.Parse(sms.Body); + Assert.Equal(Request().Message, smsJson.RootElement.GetProperty("messages")[0].GetProperty("content").GetProperty("text").GetString()); - Assert.StartsWith("https://", req.Url); - Assert.EndsWith("/sms/3/messages", req.Url); - Assert.StartsWith("App ", req.Headers["Authorization"]); - Assert.Contains("918273", req.Body); - } + 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); - [Fact] - public void TelesignUsesBasicAuthAndVoiceMapping() - { - var env = new FakeEnv(); - var req = new TelesignProvider().BuildRequest("sms", "https://rest-api.telesign.com", - Disp(message: "code 918273"), new ProviderCredential("apiKey", Secret: "key", Identity: "cust"), env); - Assert.StartsWith("Basic ", req.Headers["Authorization"]); - Assert.EndsWith("/v1/messaging", req.Url); - - var m = new TelesignProvider().Manifest; - Assert.Equal(Outcome.Continue, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusCode: "100"))); + 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); + using var callJson = JsonDocument.Parse(call.Body); + Assert.Equal(Request().Message, callJson.RootElement.GetProperty("ttsCallout").GetProperty("text").GetString()); } [Fact] - public void ProviderRegistryResolvesById() + public void OutcomesMapToPublicHttpStatuses() { - var reg = new ProviderRegistry(new IProviderAdapter[] - { - new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider(), - }); - Assert.Equal("telesign", reg.Get("TELESIGN")!.Manifest.Id); - Assert.Null(reg.Get("nope")); + Assert.Equal(403, OutcomeMapper.ToHttpStatus(Outcome.Block, 200)); + Assert.Equal(409, OutcomeMapper.ToHttpStatus(Outcome.StepUp, 200)); + Assert.Equal(429, OutcomeMapper.ToHttpStatus(Outcome.Fail, 429)); } } + +internal sealed class TestEnv : Dictionary, IEnv +{ + public string? Get(string key) => TryGetValue(key, out var value) ? value : null; +} diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 2f33322..420495e 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -1,146 +1,309 @@ using System.Net; +using System.Security.Cryptography; using System.Text; -using Epp.Otp; +using System.Text.Json; using Epp.Otp.Providers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Xunit; namespace Epp.Otp.Tests; -// Engine-level conformance tests (CONTRACT.md §6) with a fake Key Vault, HTTP client, and env. public class EngineTests { - private sealed class FakeEnv : Dictionary, IEnv - { - public string? Get(string key) => TryGetValue(key, out var value) ? value : null; - } + private const string Phone = "+15551234567"; + private const string Message = " Your code is 918273.\nDo not share. "; + private const string Nonce = "private-ack-nonce"; + private const string Kid = "private-jwe-kid"; + private const string Correlation = "private-correlation"; + private const string PrivateError = "private key/provider error: +15551234567 code 918273"; - private sealed class FakeSecretResolver : ISecretResolver - { - private readonly IReadOnlyDictionary _values; - public FakeSecretResolver(IReadOnlyDictionary values) => _values = values; - public Task ResolveAsync(string? secretName) => - Task.FromResult(secretName != null && _values.TryGetValue(secretName, out var value) ? value : string.Empty); - } - - private sealed class StubHandler : HttpMessageHandler + [Fact] + public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() { - private readonly Func _responder; - public string? LastBody; - public StubHandler(Func responder) => _responder = responder; - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + using var rig = new HandlerRig(); + Assert.Equal("soprano", AppConfig.Read(rig.Env).ProviderName); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + rig.Http.Respond = cancellation => { - if (request.Content != null) LastBody = await request.Content.ReadAsStringAsync(cancellationToken); - return _responder(request); + entered.TrySetResult(); + return release.Task.WaitAsync(cancellation); + }; + var pending = rig.Invoke(channel: "voice"); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(pending.IsCompleted); + } + finally + { + release.TrySetResult(Json(201, "{\"status\":\"ENROUTE\"}")); } + AssertAccepted(await pending); + using var body = JsonDocument.Parse(rig.Http.Body!); + Assert.Equal(Message, body.RootElement.GetProperty("text").GetString()); + 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" }) + Assert.DoesNotContain(value, log); } - private sealed class FakeHttpClientFactory : IHttpClientFactory + [Fact] + public async Task FailedHttpCannotAcknowledgeAnAcceptedBodyOrLeakProviderText() { - private readonly HttpMessageHandler _handler; - public FakeHttpClientFactory(HttpMessageHandler handler) => _handler = handler; - public HttpClient CreateClient(string name) => new(_handler); + 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); } - private sealed class CapturingLogger : ILogger + [Fact] + public async Task ResponseBodyTimeoutCancelsWithoutRetryOrSuccessNonce() { - public readonly List Lines = new(); - public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; - public bool IsEnabled(LogLevel logLevel) => true; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - => Lines.Add(formatter(state, exception)); - private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } + using var rig = new HandlerRig(); + using var body = new SlowBody(); + rig.Env["EPP_PROVIDER_TIMEOUT_MS"] = "200"; + rig.Http.Respond = _ => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) }); + AssertFailure(rig, await rig.Invoke().WaitAsync(TimeSpan.FromSeconds(5)), 504); + Assert.True(body.SawCancellationToken); + Assert.Equal(1, rig.Http.Calls); } - private static readonly Dictionary DefaultSecrets = new() + [Fact] + public async Task MissingIdentityOrKeyFailsClosedBeforeHttp() { - ["infobip-api-key"] = "ib", - ["telesign-api-key"] = "ts", ["telesign-customer-id"] = "cust", - }; + 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); + Assert.Equal(0, rig.Http.Calls); + } - private static FakeEnv DefaultEnv() => new() + [Fact] + public async Task BaseAndFinalVoiceUrlsMustBeHttpsBeforeHttp() { - ["EPP_PROVIDER_ENDPOINT"] = "https://api.infobip.com", - }; + using var rig = new HandlerRig(); + rig.Env["EPP_PROVIDER_NAME"] = "sinch"; + rig.Env["EPP_PROVIDER_ENDPOINT"] = "http://provider.example"; + AssertFailure(rig, await rig.Invoke(channel: "voice"), 502); + rig.Env["EPP_PROVIDER_ENDPOINT"] = "https://provider.example:0"; + AssertFailure(rig, await rig.Invoke(channel: "voice"), 502); + rig.Env["EPP_PROVIDER_ENDPOINT"] = "https://provider.example"; + rig.Env["SINCH_VOICE_ENDPOINT"] = "http://voice.example"; + AssertFailure(rig, await rig.Invoke(channel: "voice"), 502); + Assert.Equal(0, rig.Http.Calls); + } - private static DispatchEngine Engine(HttpResponseMessage? response = null, Exception? throwOnSend = null, - IReadOnlyDictionary? secrets = null, FakeEnv? env = null, StubHandler? handler = null) + [Fact] + public async Task EvaluationValidatesRealJweWithoutProviderConfiguration() { - var registry = new ProviderRegistry(new IProviderAdapter[] { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() }); - var stub = handler ?? new StubHandler(_ => throwOnSend != null ? throw throwOnSend : response!); - return new DispatchEngine(registry, new FakeSecretResolver(secrets ?? DefaultSecrets), new FakeHttpClientFactory(stub), env ?? DefaultEnv()); + 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")); + 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)); } - private static DispatchRequest Disp(string channel = "sms", string? message = "Your code is 918273") => - new("+15551234567", message, channel, "m", "c", null); - - private static HttpResponseMessage Json(HttpStatusCode status, string body) => - new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; - [Fact] - public async Task UnknownProvider_400() + public async Task PrivateKeyErrorsStayGenericAndNeverReachTheProvider() { - var result = await Engine(Json(HttpStatusCode.OK, "{}")).DispatchAsync(Disp(), "nope", false, "r", new CapturingLogger()); - Assert.Equal(400, result.HttpStatus); + using var rig = new HandlerRig(); + rig.Keys.Error = new InvalidOperationException(PrivateError); + AssertFailure(rig, await rig.Invoke(), 400, "decryption_failed"); + Assert.Equal(0, rig.Http.Calls); } [Fact] - public async Task MissingCredential_502() + public async Task SharedInvalidRequestsReturnSafeReasonsBeforeProviderIo() { - var result = await Engine(Json(HttpStatusCode.OK, "{}"), secrets: new Dictionary()).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); - Assert.Equal(502, result.HttpStatus); + using var rig = new HandlerRig(); + using var fixtures = ReadContractFixtures(); + foreach (var fixture in fixtures.RootElement.GetProperty("badRequests").EnumerateArray()) + { + var payload = new Dictionary + { + ["type"] = EnvelopeParser.EnvelopeType, ["channel"] = 1, ["mode"] = 1, + ["encryptedDeliveryContext"] = "unused", ["ttlSeconds"] = 60, + }; + if (fixture.TryGetProperty("overrides", out var overrides)) + foreach (var property in overrides.EnumerateObject()) payload[property.Name] = property.Value; + var raw = fixture.TryGetProperty("rawBody", out var rawBody) ? rawBody.GetString()! : JsonSerializer.Serialize(payload); + var result = await rig.InvokeRaw(raw); + AssertFailure(rig, result, 400, "bad_request"); + var body = JsonSerializer.SerializeToElement(result.Value); + Assert.False(string.IsNullOrEmpty(body.GetProperty("requestId").GetString())); + Assert.Equal(3, body.EnumerateObject().Count()); + Assert.Equal(fixture.GetProperty("reason").GetString(), body.GetProperty("reason").GetString()); + } + Assert.Equal(0, rig.Keys.Calls); + foreach (var changes in fixtures.RootElement.GetProperty("incompleteContexts").EnumerateArray()) + { + var result = await rig.Invoke("evaluation", deliveryOverrides: changes); + AssertFailure(rig, result, 400, "bad_request"); + var body = JsonSerializer.SerializeToElement(result.Value); + Assert.Equal(4, body.EnumerateObject().Count()); + Assert.Equal("incomplete delivery context", body.GetProperty("reason").GetString()); + Assert.Equal(Correlation, body.GetProperty("correlationId").GetString()); + } + Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls)); } [Fact] - public async Task MissingEndpoint_502() + public async Task SharedJwePolicyPermitsOnlyRsaOaep256WithA256Gcm() + { + using var rig = new HandlerRig(); + using var fixtures = ReadContractFixtures(); + foreach (var fixture in fixtures.RootElement.GetProperty("jwe").EnumerateArray()) + { + var alg = Enum.Parse(fixture.GetProperty("alg").GetString()!.Replace('-', '_')); + var enc = Enum.Parse(fixture.GetProperty("enc").GetString()!.Replace('-', '_')); + var accepted = fixture.GetProperty("accepted").GetBoolean(); + var result = await rig.Invoke("evaluation", algorithm: alg, encryption: enc); + if (accepted) AssertAccepted(result); + else + { + AssertFailure(rig, result, 400, "decryption_failed"); + var body = JsonSerializer.SerializeToElement(result.Value); + Assert.Equal(3, body.EnumerateObject().Count()); + Assert.Equal(Correlation, body.GetProperty("correlationId").GetString()); + } + } + Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls)); + } + + private static JsonDocument ReadContractFixtures() => + JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "fixtures", "contract.json"))); + + private static void AssertAccepted(ObjectResult result) { - var result = await Engine(Json(HttpStatusCode.OK, "{}"), env: new FakeEnv()).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); - Assert.Equal(502, result.HttpStatus); + Assert.Equal(200, result.StatusCode); + Assert.Equal(JsonSerializer.Serialize(new { nonce = Nonce, correlationId = Correlation, providerStatus = "accepted" }), + JsonSerializer.Serialize(result.Value)); } - [Fact] - public async Task Shutter_DoesNotSend_200() + private static void AssertFailure(HandlerRig rig, ObjectResult result, int status, string error = "provider_delivery_failed") { - var handler = new StubHandler(_ => throw new Exception("should not send")); - var result = await Engine(handler: handler).DispatchAsync(Disp(), "infobip", true, "r", new CapturingLogger()); - Assert.Equal(200, result.HttpStatus); - Assert.Null(handler.LastBody); + Assert.Equal(status, result.StatusCode); + var body = JsonSerializer.SerializeToElement(result.Value); + 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 }) + Assert.DoesNotContain(value, output); } - [Fact] - public async Task Success_RendersCode_AndKeepsPrivacy() + private static HttpResponseMessage Json(int status, string body) => + new((HttpStatusCode)status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + private sealed class HandlerRig : IDisposable { - var handler = new StubHandler(_ => Json(HttpStatusCode.OK, "{\"messages\":[{\"status\":{\"name\":\"DELIVERED\"},\"messageId\":\"x\"}]}")); - var logger = new CapturingLogger(); - var result = await Engine(handler: handler).DispatchAsync(Disp(), "infobip", false, "r", logger); + private readonly SendOtp _function; + public TestEnv Env { get; } + public TestSecrets Secrets { get; } = new(); + public TestHttp Http { get; } = new(); + public TestKeys Keys { get; } = new(); + public CapturingLogger Log { get; } = new(); + public HandlerRig() + { + Env = new TestEnv + { + ["EPP_PROVIDER_NAME"] = "soprano", + ["EPP_PROVIDER_ENDPOINT"] = "https://provider.example/cgpapi", + ["EPP_PROVIDER_TIMEOUT_MS"] = "2500", + }; + var registry = new ProviderRegistry(new IProviderAdapter[] + { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() }); + _function = new SendOtp(new DispatchEngine(registry, Secrets, Http, Env), + new JweDecryptor(Keys), Env, Log); + } + 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) + { + var context = new Dictionary { ["nonce"] = Nonce, ["phoneNumber"] = Phone, ["message"] = Message }; + if (deliveryOverrides is { } changes) + foreach (var property in changes.EnumerateObject()) context[property.Name] = property.Value; + var encrypted = Jose.JWT.Encode(JsonSerializer.Serialize(context), Keys.Rsa, algorithm, encryption, + extraHeaders: new Dictionary { ["kid"] = Kid }); + return await InvokeRaw(JsonSerializer.Serialize(new + { + type = EnvelopeParser.EnvelopeType, tenantId, correlationId = Correlation, channel, mode = mode ?? "live", + ttlSeconds = 60, encryptedDeliveryContext = encrypted, + })); + } + public async Task InvokeRaw(string body) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); + var request = new DefaultHttpContext().Request; + request.Method = "POST"; + request.ContentType = "application/json"; + request.Body = stream; + return Assert.IsAssignableFrom(await _function.Run(request)); + } + public void Dispose() { Keys.Dispose(); Http.Dispose(); } + } - Assert.Equal(200, result.HttpStatus); - Assert.Contains("918273", handler.LastBody); // message (with the code) IS sent to the provider - var bodyJson = System.Text.Json.JsonSerializer.Serialize(result.Body); - Assert.DoesNotContain("918273", bodyJson); // never in the response body - Assert.DoesNotContain("5551234567", bodyJson); - Assert.All(logger.Lines, line => Assert.DoesNotContain("918273", line)); // never logged - Assert.All(logger.Lines, line => Assert.DoesNotContain("5551234567", line)); + private sealed class TestSecrets : ISecretResolver + { + public int Calls { get; private set; } + 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); + } } - [Fact] - public async Task UnknownStatus_FailsClosed() + private sealed class TestHttp : HttpMessageHandler, IHttpClientFactory { - var result = await Engine(Json(HttpStatusCode.OK, "{\"messages\":[{\"status\":{\"name\":\"WATWAT\"}}]}")).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); - Assert.Equal(502, result.HttpStatus); // Fail on HTTP 200 -> 502 + public int Calls { get; private set; } + public string? Body { 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++; + Body = await request.Content!.ReadAsStringAsync(cancellationToken); + return await Respond(cancellationToken); + } } - [Fact] - public async Task Timeout_504() + private sealed class CapturingLogger : ILogger { - var result = await Engine(throwOnSend: new TaskCanceledException()).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); - Assert.Equal(504, result.HttpStatus); + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + public IEnumerable Messages => Entries.Select(entry => entry.Message); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel level, EventId id, TState state, Exception? error, Func formatter) => + Entries.Add((level, formatter(state, error) + (error?.ToString() ?? ""))); } - [Fact] - public async Task NetworkError_502() + // Headers arrive immediately; only reading the response body stalls until cancellation. + private sealed class SlowBody : MemoryStream { - var result = await Engine(throwOnSend: new HttpRequestException("dns")).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); - Assert.Equal(502, result.HttpStatus); + public bool SawCancellationToken { get; private set; } + public SlowBody() : base(new byte[] { 0 }) { } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + SawCancellationToken = cancellationToken.CanBeCanceled; + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } } } diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs index 9baede5..304a156 100644 --- a/dotnet/tests/EnvelopeTests.cs +++ b/dotnet/tests/EnvelopeTests.cs @@ -1,80 +1,76 @@ using System.Security.Cryptography; +using System.Text; using System.Text.Json; -using Epp.Otp; using Xunit; namespace Epp.Otp.Tests; -// Envelope validation + JWE decryption round-trip (see docs/CONTRACT.md §1, §6). public class EnvelopeTests { - private static JsonElement Payload(string json) => JsonDocument.Parse(json).RootElement; - - private sealed class FakeKeyProvider : IJweKeyProvider - { - private readonly RSA _rsa; - public FakeKeyProvider(RSA rsa) => _rsa = rsa; - public RSA GetPrivateKey(string? kid) => _rsa; - } - - [Fact] - public void MissingEncryptedContext_IsError() + [Theory] + [InlineData("\"channel\":1,\"mode\":2,\"ttlSeconds\":60", "sms", 2, 60)] + [InlineData("\"channel\":\"VOICE\",\"mode\":\"Live\"", "voice", 1, null)] + public void NumericAndNamedRoutingParse(string routing, string channel, int mode, int? ttl) { - var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":1,\"mode\":1}")); - Assert.Null(envelope); - Assert.Contains("encryptedDeliveryContext", error); + var (envelope, error) = Parse(routing); + Assert.Null(error); + Assert.NotNull(envelope); + Assert.Equal(channel, EnvelopeParser.ChannelName(envelope.Channel)); + Assert.Equal(mode, envelope.Mode); + Assert.Equal(ttl, envelope.TtlSeconds); } [Fact] - public void UnsupportedChannel_IsError() + public void RealJweRejectsTagTamperingAndMissingSegments() { - var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":9,\"mode\":1,\"encryptedDeliveryContext\":\"x\"}")); - Assert.Null(envelope); - Assert.Contains("channel", error); + using var keys = new TestKeys(); + var decryptor = new JweDecryptor(keys); + var compact = Jose.JWT.Encode("{\"nonce\":\"private-nonce\"}", keys.Rsa, + Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); + var parts = compact.Split('.'); + parts[4] = (parts[4][0] == 'A' ? "B" : "A") + parts[4][1..]; + Assert.ThrowsAny(() => decryptor.Decrypt(string.Join(".", parts))); + Assert.ThrowsAny(() => decryptor.Decrypt(string.Join(".", parts.Take(4)))); } [Fact] - public void UnsupportedMode_IsError() + public void JweAuthenticatesOriginalProtectedHeaderBytes() { - var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":1,\"mode\":5,\"encryptedDeliveryContext\":\"x\"}")); - Assert.Null(envelope); - Assert.Contains("mode", error); + using var keys = new TestKeys(); + const string header = "{ \"kid\" : \"test-key\", \"enc\" : \"A256GCM\", \"alg\" : \"RSA-OAEP-256\" }"; + static string Encode(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + 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\"}"); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[16]; + using var cipher = new AesGcm(key, tag.Length); + cipher.Encrypt(iv, plaintext, ciphertext, tag, Encoding.ASCII.GetBytes(encodedHeader)); + var wrappedKey = keys.Rsa.Encrypt(key, RSAEncryptionPadding.OaepSHA256); + var segments = new[] { encodedHeader, Encode(wrappedKey), Encode(iv), Encode(ciphertext), Encode(tag) }; + var decryptor = new JweDecryptor(keys); + Assert.Equal("test-nonce", decryptor.Decrypt(string.Join(".", segments)).Context.Nonce); + segments[0] = Encode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(JsonSerializer.Deserialize(header)))); + Assert.NotEqual(encodedHeader, segments[0]); + Assert.ThrowsAny(() => decryptor.Decrypt(string.Join(".", segments))); } - [Fact] - public void ValidEnvelope_Parses() - { - var (envelope, error) = EnvelopeParser.Parse(Payload( - "{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"tenantId\":\"t\",\"correlationId\":\"c\",\"channel\":2,\"mode\":1,\"ttlSeconds\":60,\"encryptedDeliveryContext\":\"x\"}")); - Assert.Null(error); - Assert.NotNull(envelope); - Assert.Equal(2, envelope!.Channel); - Assert.Equal(1, envelope.Mode); - Assert.Equal("voice", EnvelopeParser.ChannelName(envelope.Channel)); - } + private static (Envelope? Envelope, string? Error) Parse(string routing) => + EnvelopeParser.Parse(JsonSerializer.Deserialize( + "{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"encryptedDeliveryContext\":\"x\"," + routing + "}")); +} - [Fact] - public void Jwe_RoundTrips_ToDeliveryContext() +internal sealed class TestKeys : IJweKeyProvider, IDisposable +{ + public RSA Rsa { get; } = RSA.Create(2048); + public int Calls { get; private set; } + public Exception? Error { get; set; } + public RSA GetPrivateKey(string? kid) { - using var rsa = RSA.Create(2048); - var contextJson = JsonSerializer.Serialize(new - { - nonce = "nonce-1", - phoneNumber = "+14255551234", - message = "Your code is 123456", - locale = "en-US", - }); - var jwe = Jose.JWT.Encode(contextJson, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM, - extraHeaders: new Dictionary { ["kid"] = "test-key" }); - - var decrypted = new JweDecryptor(new FakeKeyProvider(rsa)).Decrypt(jwe); - Assert.Equal("test-key", decrypted.Kid); - Assert.Equal("RSA-OAEP-256", decrypted.Alg); - Assert.Equal("A256GCM", decrypted.Enc); - var context = decrypted.Context; - Assert.Equal("nonce-1", context.Nonce); - Assert.Equal("+14255551234", context.PhoneNumber); - Assert.Equal("Your code is 123456", context.Message); - Assert.Equal("en-US", context.Locale); + Calls++; + if (Error is not null) throw Error; + return Rsa; } + public void Dispose() => Rsa.Dispose(); } diff --git a/dotnet/tests/Epp.Otp.Tests.csproj b/dotnet/tests/Epp.Otp.Tests.csproj index d103f94..69ddf3b 100644 --- a/dotnet/tests/Epp.Otp.Tests.csproj +++ b/dotnet/tests/Epp.Otp.Tests.csproj @@ -8,21 +8,23 @@ + + + - - - - + + + diff --git a/javascript/.funcignore b/javascript/.funcignore new file mode 100644 index 0000000..90d2ac2 --- /dev/null +++ b/javascript/.funcignore @@ -0,0 +1,12 @@ +local.settings*.json +**/local.settings*.json +.env* +**/.env* +.keys/ +**/.keys/ +**/*.pem +**/*.pfx +**/*.key +test/ +coverage/ +.vscode/ \ No newline at end of file diff --git a/javascript/README.md b/javascript/README.md index ea6925c..bf6254e 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -1,203 +1,62 @@ -# External Phone Provider — Azure Function (delivery endpoint) - -An Azure Function (Node.js) that receives an OTP dispatch request and forwards it to a telephony -provider (**Infobip**, **Telesign**, **Sinch**, or **Soprano**). - -## What it does - -- `POST /api/SendOtp` — dispatches the OTP to the selected provider and returns an accepted/failed result. -- Provider secrets from **Azure Key Vault** (managed identity). -- **Correlation id** propagated to the provider and echoed back. -- **Shutter mode** — process the full path but do not send. -- **Token validation** — Easy Auth is the primary gate; `EPP_REQUIRE_AUTH=true` adds an in-process backstop. - -> **Turn Easy Auth ON.** The trigger is `authLevel: anonymous`, so App Service Authentication -> (`unauthenticatedClientAction=Return401`, `allowedApplications` pinned to Microsoft's app) is what -> keeps the endpoint closed. Set **`EPP_REQUIRE_AUTH=true`** as well in any real deployment — it is the -> backstop if Easy Auth is ever misconfigured, and the only gate when running locally. - -## Deploy - -1. **Create the Key Vault** and add what the Function reads: - - the provider **API key/token** as a **secret** (default name `infobip-api-key` — see [Configuration](#configuration)). -2. **Grant the Function's managed identity** on that vault: **Key Vault Secrets User**. -3. **Set the app settings** — copy [`../docs/local.settings.sample.json`](../docs/local.settings.sample.json) into `src/local.settings.json` locally; in Azure set them under **Function App → Settings → Environment variables**. At minimum set `EPP_DECRYPTION_KEY_PEM`, `EPP_PROVIDER_NAME`, and `EPP_PROVIDER_ENDPOINT`; see [Configuration](#configuration) for the full list. -4. **Publish:** - -```bash -cd src -npm install -func azure functionapp publish -``` - -## Configuration - -The endpoint is **plug-and-play by provider**. The **shared infrastructure** — token validation, dispatch, -response normalization, message templating, and -logging — is identical for every provider and needs no per-provider code. You **choose one provider**; -the only provider-specific parts are its **adapter** (the outbound API call) and the **few settings** below. - -> A new provider is onboarded by dropping in a single file `providers/.js` that exports its -> `manifest` (built-in defaults: endpoints, channels, auth, responseMapping) plus `buildRequest` / -> `parseResponse` — no change to the shared pipeline. - -> **Provisioning model.** The provider's authoritative parameters live in its **Security Store package -> manifest**. At provisioning time, UX reads that manifest and sets the operational values as **app -> settings (env properties)** on the Function — the endpoint URL (`EPP_PROVIDER_ENDPOINT`), sender/source -> id (`EPP_PROVIDER_ACCOUNT_NAME`), `EPP_PROVIDER_TIMEOUT_MS`, and the Key Vault secret references. The values baked -> into `providers/.js` are just **local-dev defaults**; the app settings win. Only the **adapter -> code** (`buildRequest`/`parseResponse`) is provider-specific code — everything else is data. - -Provider secrets are read from **Key Vault** by name via the Function's managed identity. Set -`KEY_VAULT_URL` to the vault URI. Each provider's secret name is fixed in its manifest -(`infobip-api-key` / `telesign-api-key` / `sinch-api-token` / `soprano-api-key`); the value lives in -Key Vault and can be rotated there without a redeploy. - -### Shared settings (always) - -| Key | Purpose | -|-----|---------| -| `EPP_PROVIDER_NAME` | your chosen provider: `infobip` \| `telesign` \| `sinch` \| `soprano` | -| `EPP_PROVIDER_ENDPOINT` | provider base URL (one provider is active per deployment) | -| `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider | -| `EPP_PROVIDER_TIMEOUT_MS` | outbound provider-call timeout in ms (default `1500`) | -| `EPP_DECRYPTION_KEY_PEM` | RSA private key PEM for JWE decryption — a **Key Vault reference** in Azure | -| `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | -| `EPP_EXPECTED_CLIENT_ID` | caller `appid`/`azp` to admit; Easy Auth returns `403`, in-process validation returns `401` | -| `EPP_REQUIRE_AUTH` | **set `true` in any real deployment** — validates the token in-process as a backstop to Easy Auth | -| `EPP_EXPECTED_AUDIENCE` | token `aud` (this endpoint's app registration appId) — required when `EPP_REQUIRE_AUTH=true` | -| `EPP_TENANT_ID` | customer tenant id for issuer/JWKS — required when `EPP_REQUIRE_AUTH=true` | -| `EPP_EXPECTED_ISSUER` | optional; pins a single issuer instead of accepting both v1 and v2 | -| `EPP_LOG_PLAINTEXT` | **diagnostics only** — `true` writes the phone number and passcode to the log. Never enable in production | -| `KEY_VAULT_URL` | Key Vault URI (provider API keys) | - -### Per-provider settings (set only for the provider you chose) - -Set `EPP_PROVIDER_NAME` to your provider, then provision **only that block** — its **Key Vault secret** -(the API key/token — the *only* secret) plus the shared `EPP_PROVIDER_ENDPOINT` / -`EPP_PROVIDER_ACCOUNT_NAME`. Endpoints, sender ids, `KEY_VAULT_URL`, and the Key Vault secret **names** -are all non-secret configuration; only the key/token **value** lives in Key Vault. - -**Infobip** -| Setting | Purpose | -|---------|---------| -| Key Vault secret `infobip-api-key` | API key | -| `INFOBIP_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | registered sender, app setting (default `Verify`) | - -**Telesign** -| Setting | Purpose | -|---------|---------| -| Key Vault secret `telesign-api-key` | API key | -| Key Vault secret `telesign-customer-id` | customer id (the Basic-auth username) | -| `TELESIGN_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | sender id, app setting (optional) | -| `TELESIGN_VOICE` | voice language/voice code for voice OTP, app setting (optional; default `f-en-US`) | - -**Sinch** -| Setting | Purpose | -|---------|---------| -| Key Vault secret `sinch-api-token` | API token | -| `SINCH_SERVICE_PLAN_ID` | XMS service plan id, app setting | -| `SINCH_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | sender, app setting (default `Verify`) | -| `SINCH_VOICE_ENDPOINT` | Sinch Voice API host, app setting (optional; default `https://calling.api.sinch.com`) | - -**Soprano** -| Setting | Purpose | -|---------|---------| -| Key Vault secret `soprano-api-key` | API key (sent as the `X-MEMS-API-Key` header) | -| Key Vault secret `soprano-api-id` | API ID (sent as the `X-MEMS-API-ID` header) | -| `EPP_PROVIDER_ENDPOINT` | **required** — your MEMS API base `https:///cgpapi` (per-customer; no default) | -| `EPP_PROVIDER_ACCOUNT_NAME` | the provisioned source/sender endpoint id — Soprano requires a provisioned sender, so a **numeric** value is sent as `endpoints:[{type,id}]`; a non-numeric one falls back to a free-text `source` | -| `SOPRANO_SOURCE_TYPE` | provisioned source endpoint type, app setting (optional; default `1`) | - -> `EPP_PROVIDER_ENDPOINT` is the provider base URL for the one active provider (e.g. a sandbox host). - -### Identity & permissions (managed identity — no static credentials) - -The Function authenticates to Key Vault (and any other Azure resource) with its **managed identity** (user-assigned when `AZURE_CLIENT_ID` is set, else system-assigned) — there are **no secrets, keys, or connection strings in code or config**. Grant it **least-privilege** access on the customer's vault: - -| Scope | Role | Why | -|-------|------|-----| -| The provider **secret** (or the vault) | **Key Vault Secrets User** | `get` the provider API key/token | - -Also use an **identity-based** `AzureWebJobsStorage` connection (managed identity) instead of a storage connection string, so the runtime holds no static secret either. All resource **names** (`KEY_VAULT_URL`, `EPP_EXPECTED_AUDIENCE`, `EPP_TENANT_ID`) come from app settings — nothing is hard-coded. - -### Add your own provider - -Onboarding a provider is **one file** — `src/functions/providers/.js` — with no change to the shared pipeline. Copy an existing provider (e.g. [infobip.js](src/functions/providers/infobip.js)) and export three things: - -```js -// 1) manifest — the provider's protocol facts the engine reads -const manifest = { - id: 'acme', // provider id (used as the `Provider` value); the URL - // app setting is `_ENDPOINT`, e.g. ACME_ENDPOINT - auth: { mode: 'apiKey', keyVaultSecretName: 'acme-api-key' }, - responseMapping: { SENT: 'Continue', FAILED: 'Fail', default: 'Fail' }, // provider status → outcome -}; - -// 2) buildRequest — shape the outbound HTTP call -function buildRequest({ channel, endpoint, dispatch, credential, env }) { - return { - url: `${endpoint}/messages`, - method: 'POST', - headers: { Authorization: `Bearer ${credential.secret}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ to: dispatch.destination, text: dispatch.message }), - }; -} - -// 3) parseResponse — normalize the provider reply -function parseResponse({ httpStatus, ok, json }) { - return { - success: ok, - providerHttpStatus: httpStatus, - providerMessageId: (json && json.id) || null, - providerStatusName: (json && json.status) || (ok ? 'SENT' : null), - providerStatusDescription: (json && json.description) || null, - }; -} - -module.exports = { manifest, buildRequest, parseResponse }; -``` - -The engine handles the rest — provider resolution, Key Vault credential fetch (via managed identity), message templating, timeout, `responseMapping` → HTTP status, and fail-closed behavior. Drop the file in, add the Key Vault secret, set `EPP_PROVIDER_NAME=acme`, and it works. - -## Request contract - -`POST /api/SendOtp` — the SAS → External Phone Provider delivery endpoint. The cleartext body is a routing envelope; the -PII (phone + rendered message, which contains the passcode) is encrypted in a JWE. See -[../docs/CONTRACT.md](../docs/CONTRACT.md) for the full contract. - -| Field | Required | Notes | -|-------|----------|-------| -| `type` | yes | envelope version, e.g. `microsoft.mfa.otpDeliver.v1` | -| `channel` | yes | `1`=Sms, `2`=Voice | -| `mode` | yes | `1`=Live, `2`=Evaluation (rehearsal — not delivered) | -| `encryptedDeliveryContext` | yes | JWE (RSA-OAEP-256 + A256GCM); decrypts to `{ nonce, phoneNumber, message, locale?, riskContext? }` | -| `tenantId`, `correlationId`, `ttlSeconds` | no | routing / tracing / passcode validity | - -The active provider is deployment config (`EPP_PROVIDER_NAME`), not a request field. The response is the -`CyotEndpointResponse`: `{ "nonce": "", "correlationId": "", "providerStatus": "accepted" }`. -A `2xx` with a matching nonce means handled; non-2xx / nonce mismatch / timeout → SAS falls back to CAPP. - -## Try it - -The private RSA key that decrypts `encryptedDeliveryContext` is resolved from Key Vault by the JOSE `kid` -(`EPP_DECRYPTION_KEY_PEM`, a Key Vault reference). Build the envelope with the matching public key: - -```bash -curl -X POST https://.azurewebsites.net/api/SendOtp \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -H "x-ms-correlation-id: test-001" \ - -d '{ - "type": "microsoft.mfa.otpDeliver.v1", - "tenantId": "", - "correlationId": "test-001", - "channel": 1, - "mode": 1, - "ttlSeconds": 60, - "encryptedDeliveryContext": "" - }' -# -> 200 { "nonce": "", "correlationId": "test-001", "providerStatus": "accepted" } -``` - -Evaluation mode (`"mode": 2`) runs everything except the actual send and still echoes the nonce. +# 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. + +## Setup + +1. Follow [customer onboarding](../docs/ONBOARDING.md). Choose a registered adapter and set + `EPP_PROVIDER_NAME` to its manifest id; `` is a placeholder, not a default. +2. Consult the selected adapter and its manifest in [src/functions/providers/](src/functions/providers/) + for required credentials and options. Store credential values under the manifest's Key Vault + secret names, grant the Function's managed identity *Key Vault Secrets User*, and configure the + matching endpoint and required options. This guide does not duplicate individual API contracts. +3. Use [../docs/local.settings.sample.json](../docs/local.settings.sample.json) as a starting point, + replacing placeholders with the selected adapter's settings. Keep local settings private at + the app root beside [host.json](host.json), with `FUNCTIONS_WORKER_RUNTIME=node`. +4. Configure decryption from the [shared catalog](../docs/CONTRACT.md#4-configuration-app-settings--env). + Follow [platform trust setup](../docs/ONBOARDING.md#2-provision-encryption-and-deployment-trust): Easy + Auth is the only caller-authentication gate before the anonymous Function. Enable + `requireAuthentication=true`, `unauthenticatedClientAction=Return401` and `requireHttps=true`; pin the + trusted tenant issuer, endpoint-app `allowedAudiences` and a nonempty `allowedApplications` list for + the authorized SAS caller. Do not exclude SendOtp. There is no backup application token validation; + never expose the endpoint to the public internet with Easy Auth disabled or bypassed. +5. Install dependencies from [package.json](package.json), run its offline test script and start the + local Functions host from this folder. Core Tools has no Easy Auth: bind only to loopback, with no + tunnels or public forwarding. Publish this app folder only, preserving dependencies and observing + [.funcignore](.funcignore); inspect the package before upload. Offline tests cover application + behavior, not platform authentication; run the separate + [deployed security checks](../docs/ONBOARDING.md#4-package-deploy-and-verify). + +## Request behavior + +Easy Auth authenticates and authorizes the caller before `POST /api/SendOtp`; the anonymous handler +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. + +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. +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. + +## Source and extension points + +| Source | Purpose | +|---|---| +| [src/functions/SendOtp.js](src/functions/SendOtp.js) | HTTP handler | +| [src/functions/config.js](src/functions/config.js) | Shared deployment settings | +| [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 | + +To add an adapter, implement `manifest`, `buildRequest` and `parseResponse` in the adapter folder and +register it in [src/functions/dispatch.js](src/functions/dispatch.js). 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. diff --git a/javascript/package.json b/javascript/package.json index 289d84d..bacac39 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -1,7 +1,7 @@ { "name": "epp-otp-sample", "version": "1.0.0", - "description": "External Phone Provider delivery endpoint - send OTP via Infobip, Telesign, Sinch, or Soprano", + "description": "OTP delivery endpoint with configurable provider adapters", "main": "src/functions/*.js", "scripts": { "start": "func start", diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index 7d30ebc..08fbbc7 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -4,13 +4,8 @@ 'use strict'; -// POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the caller, parses -// the cleartext routing envelope, decrypts the JWE delivery context, dispatches to the provider, and -// echoes the nonce to prove decryption. Every line is tagged [EPP] so one filter pulls a whole delivery. - const { app } = require('@azure/functions'); const crypto = require('crypto'); -const { validateToken } = require('./security'); const { dispatchOtp, parseEnvelope, @@ -18,173 +13,85 @@ const { contextToDispatch, MODE, } = require('./dispatch'); -const { readConfig, missingSettings } = require('./config'); - -const TAG = '[EPP]'; - -// Easy Auth has already validated the token by the time this runs; this records which identity arrived. -function readCallerAppId(request) { - const encoded = request.headers.get('x-ms-client-principal'); - if (!encoded) return undefined; - try { - const principal = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); - const claim = (principal.claims || []).find((c) => c.typ === 'appid' || c.typ === 'azp'); - return claim && claim.val; - } catch { - return undefined; - } -} - -const pad = (label) => label.padEnd(18, ' '); - -// The handler deliberately does not await the provider, so tests need a handle on the send it started. -let pendingDelivery = Promise.resolve(); -const whenDelivered = () => pendingDelivery; - -// Microsoft allows 3.2 s for the whole call, so the provider is called after the response. -function deliverInBackground(dispatch, evaluation, context, requestId) { - pendingDelivery = dispatchOtp(dispatch, { shutter: evaluation, context, requestId }) - .then(({ httpStatus, body }) => { - context.log(`${TAG} provider result : httpStatus=${httpStatus} outcome=${body.outcome || 'n/a'} providerStatus=${body.providerStatus || 'n/a'} providerMessageId=${body.providerMessageId || 'n/a'}`); - }) - .catch((deliveryError) => { - (context.error || context.log).call(context, `${TAG} provider delivery failed: ${deliveryError.message}`); - }); - return pendingDelivery; -} +const { readConfig } = require('./config'); app.http('SendOtp', { methods: ['POST'], - authLevel: 'anonymous', // Easy Auth is the gate; EPP_REQUIRE_AUTH adds in-process token validation. + authLevel: 'anonymous', // Protected by platform authentication in Azure. handler: async (request, context) => { const started = Date.now(); - const config = readConfig(); - const log = (label, value) => context.log(`${TAG} ${pad(label)}: ${value}`); - const warn = (message) => (context.warn || context.log).call(context, `${TAG} ${message}`); - const error = (message) => (context.error || context.log).call(context, `${TAG} ${message}`); - const requestId = crypto.randomUUID(); - const clientRequestId = request.headers.get('x-ms-client-request-id') || requestId; - const headerCorrelationId = request.headers.get('x-ms-correlation-id') || null; + let correlationId = requestId; + let evaluation = false; + let httpStatus = 500; + const respond = (status, jsonBody) => { + httpStatus = status; + return { status, jsonBody }; + }; - context.log(`${TAG} ======== delivery received ========`); - log('invocation', context.invocationId || requestId); - - let envelope; try { - // Logged, not thrown: a missing provider setting still lets this prove decryption works. - const absent = missingSettings(config); - if (absent.length > 0) { - warn(`settings not set: ${absent.join(', ')}`); - } - - const callerAppId = readCallerAppId(request); - log('caller appid', callerAppId || 'none (Easy Auth off, or called directly)'); - - if (callerAppId && config.expectedClientId && callerAppId !== config.expectedClientId) { - error(`caller ${callerAppId} is not ${config.expectedClientId}. ` + - 'Easy Auth allowedApplications is not doing its job.'); - return { status: 403, jsonBody: { error: 'unexpected_caller' } }; - } - - const tokenValidation = await validateToken(request, context, requestId); - if (!tokenValidation.ok) { - error(`token rejected: ${tokenValidation.reason}`); - return { status: 401, jsonBody: { error: 'unauthorized', reason: tokenValidation.reason, requestId } }; - } + const config = readConfig(); + const clientRequestId = request.headers.get('x-ms-client-request-id') || requestId; + const headerCorrelationId = request.headers.get('x-ms-correlation-id') || null; + correlationId = headerCorrelationId || requestId; let payload; try { payload = JSON.parse(await request.text()); - } catch (parseError) { - error(`body is not JSON: ${parseError.message}`); - return { status: 400, jsonBody: { error: 'bad_request', reason: 'invalid JSON body', requestId } }; + } catch { + return respond(400, { error: 'bad_request', reason: 'invalid JSON body', requestId }); } const parsed = parseEnvelope(payload); if (parsed.error) { - error(`envelope rejected: ${parsed.error}`); - return { status: 400, jsonBody: { error: 'bad_request', reason: parsed.error, requestId } }; + return respond(400, { error: 'bad_request', reason: parsed.error, requestId }); } - envelope = parsed.envelope; - - log('type', envelope.type); - log('tenantId', envelope.tenantId); - log('correlationId', envelope.correlationId); - log('channel', envelope.channel); - log('mode', envelope.mode); - log('ttlSeconds', envelope.ttlSeconds); + const envelope = parsed.envelope; + correlationId = envelope.correlationId || headerCorrelationId || requestId; + evaluation = envelope.mode === MODE.EVALUATION; - const correlationId = envelope.correlationId || headerCorrelationId || requestId; - - // Surfaced rather than swallowed: the passcode expires before it can be used. - if (envelope.ttlSeconds !== undefined && envelope.ttlSeconds <= 0) { - warn(`ttlSeconds is ${envelope.ttlSeconds}; the passcode has expired.`); - } - - let header; let delivery; + let header; try { - ({ header, context: delivery } = await decryptDeliveryContext( + ({ context: delivery, header } = await decryptDeliveryContext( envelope.encryptedDeliveryContext, config)); - } catch (decryptError) { - error(`decryption failed: ${decryptError.message}`); - return { status: 400, jsonBody: { error: 'decryption_failed', correlationId, requestId } }; + } catch { + return respond(400, { error: 'decryption_failed', correlationId, requestId }); } - const kidMatches = !config.expectedKeyId || header.kid === config.expectedKeyId; - log('kid', `${header.kid}${kidMatches ? '' : ' (DOES NOT match EPP_ENCRYPTION_KEY_ID)'}`); - log('alg / enc', `${header.alg} / ${header.enc}`); - log('decrypted', 'OK'); - log('nonce', delivery.nonce); - - if (config.logPlaintext) { - // DIAGNOSTICS ONLY — writes the phone number and passcode to the log. - log('phoneNumber', delivery.phoneNumber); - log('extension', delivery.extension || '(none)'); - log('locale', delivery.locale); - log('message', delivery.message); - log('riskContext', delivery.riskContext ? JSON.stringify(delivery.riskContext) : '(none)'); - } else { - context.log(`${TAG} plaintext suppressed (EPP_LOG_PLAINTEXT=false)`); + // Key ID is advisory after authenticated decryption. + if (config.expectedKeyId && config.expectedKeyId !== header.kid) { + context.warn('encryption_key_id_mismatch'); } - if (!delivery.nonce || !delivery.phoneNumber || !delivery.message) { - error('delivery context is incomplete (nonce/phoneNumber/message)'); - return { status: 400, jsonBody: { error: 'bad_request', reason: 'incomplete delivery context', correlationId, requestId } }; + if (!delivery || typeof delivery !== 'object' || Array.isArray(delivery) + || ['nonce', 'phoneNumber', 'message'].some((field) => typeof delivery[field] !== 'string' || !delivery[field].trim())) { + return respond(400, { error: 'bad_request', reason: 'incomplete delivery context', correlationId, requestId }); } - const evaluation = envelope.mode === MODE.EVALUATION; - const dispatch = contextToDispatch(delivery, envelope, clientRequestId); - - deliverInBackground(dispatch, evaluation, context, requestId); - - // Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery - // and Microsoft re-sends over its own telephony, so the user gets the code twice. - const body = { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }; - - log('responding', `200, nonce echoed, ${Date.now() - started} ms`); - context.log(`${TAG} ======== done ========`); - - return { status: 200, jsonBody: body }; - } catch (unhandled) { - // Verbose on purpose: this endpoint exists to diagnose onboarding. - error(`FAILED after ${Date.now() - started} ms: ${unhandled.message}`); - if (unhandled.cause) { - error(`caused by: ${unhandled.cause.message || unhandled.cause}`); + // Evaluation proves decryption without resolving a provider or requiring provider config. + if (!evaluation) { + const dispatch = contextToDispatch(delivery, envelope, clientRequestId); + dispatch.correlationId = correlationId; + const result = await dispatchOtp(dispatch, { requestId, config }).catch(() => ({ httpStatus: 500 })); + if (result.httpStatus !== 200) { + return respond(result.httpStatus, { error: 'provider_delivery_failed', correlationId, requestId }); + } } - context.log(`${TAG} ======== failed ========`); - return { - status: 500, - jsonBody: { - error: 'delivery_failed', - detail: unhandled.message, - correlationId: envelope && envelope.correlationId, - }, - }; + // Only a successful delivery (or validated evaluation) may echo the nonce and accepted. + return respond(200, { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }); + } catch { + return respond(500, { error: 'delivery_failed', correlationId, requestId }); + } finally { + const rawId = typeof correlationId === 'string' ? correlationId : JSON.stringify(correlationId); + context.log({ + requestId, + correlationId: crypto.createHash('sha256').update(rawId).digest('hex').slice(0, 16), + httpStatus, + elapsedMs: Date.now() - started, + evaluation, + }); } }, }); - -module.exports = { whenDelivered }; diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js index 79be0f2..9d6603e 100644 --- a/javascript/src/functions/config.js +++ b/javascript/src/functions/config.js @@ -4,35 +4,17 @@ 'use strict'; -// Settings the request handler needs. dispatch.js and security.js read their own directly. - -function readConfig() { - const env = process.env; +function readConfig(env = process.env) { return { decryptionKeyPem: env.EPP_DECRYPTION_KEY_PEM || '', expectedKeyId: env.EPP_ENCRYPTION_KEY_ID || '', - expectedAudience: env.EPP_EXPECTED_AUDIENCE || '', - expectedClientId: env.EPP_EXPECTED_CLIENT_ID || '', - tenantId: env.EPP_TENANT_ID || '', - // PII in the log. Diagnostics only, and must stay false in production. - logPlaintext: String(env.EPP_LOG_PLAINTEXT || '').toLowerCase() === 'true', - requireAuth: String(env.EPP_REQUIRE_AUTH || '').toLowerCase() === 'true', - provider: { - name: env.EPP_PROVIDER_NAME || '', - endpoint: env.EPP_PROVIDER_ENDPOINT || '', - }, + providerName: (env.EPP_PROVIDER_NAME || '').trim().toLowerCase(), + providerEndpoint: env.EPP_PROVIDER_ENDPOINT || '', + providerTimeoutMs: env.EPP_PROVIDER_TIMEOUT_MS || '', + keyVaultUrl: (env.KEY_VAULT_URL || '').trim(), + managedIdentityClientId: (env.AZURE_CLIENT_ID || '').trim(), + env, }; } -// Reported, never thrown: a missing provider setting still lets the delivery prove decryption. -function missingSettings(config) { - const absent = []; - if (!config.decryptionKeyPem) absent.push('EPP_DECRYPTION_KEY_PEM'); - if (!config.provider.name) absent.push('EPP_PROVIDER_NAME'); - if (!config.provider.endpoint) absent.push('EPP_PROVIDER_ENDPOINT'); - if (config.requireAuth && !config.expectedAudience) absent.push('EPP_EXPECTED_AUDIENCE'); - if (config.requireAuth && !config.tenantId) absent.push('EPP_TENANT_ID'); - return absent; -} - -module.exports = { readConfig, missingSettings }; +module.exports = { readConfig }; diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index 61ba4ae..1e48de2 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -4,53 +4,62 @@ 'use strict'; -// Delivery pipeline: parse the cleartext SAS envelope, decrypt the JWE that carries the PII, then -// dispatch to the configured provider. Fail-closed — only a Continue outcome is "accepted". - const crypto = require('crypto'); const { compactDecrypt } = require('jose'); const { ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('./config'); -// CyotChannel: 1=Sms, 2=Voice. CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); const MODE = Object.freeze({ LIVE: 1, EVALUATION: 2 }); const MODE_BY_NAME = Object.freeze({ live: 1, evaluation: 2 }); -// channel/mode accept the int enum (1/2) or the string form ("sms"/"voice", "live"/"evaluation"). function normalizeChannel(channel) { - if (CHANNEL_BY_CODE[channel]) return Number(channel); - if (typeof channel === 'string' && CHANNEL_BY_NAME[channel.toLowerCase()]) return CHANNEL_BY_NAME[channel.toLowerCase()]; + if (channel === 1 || channel === 2) return channel; + if (typeof channel === 'string' && Object.hasOwn(CHANNEL_BY_NAME, channel.toLowerCase())) { + return CHANNEL_BY_NAME[channel.toLowerCase()]; + } return null; } function normalizeMode(mode) { if (mode === MODE.LIVE || mode === MODE.EVALUATION) return mode; - if (typeof mode === 'string' && MODE_BY_NAME[mode.toLowerCase()]) return MODE_BY_NAME[mode.toLowerCase()]; + if (typeof mode === 'string' && Object.hasOwn(MODE_BY_NAME, mode.toLowerCase())) { + return MODE_BY_NAME[mode.toLowerCase()]; + } return null; } function parseEnvelope(payload) { - if (!payload || typeof payload !== 'object') { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return { error: 'invalid envelope' }; } const { type, tenantId, correlationId, channel, mode, ttlSeconds, encryptedDeliveryContext } = payload; - if (typeof encryptedDeliveryContext !== 'string' || !encryptedDeliveryContext) { + if (type !== 'microsoft.mfa.otpDeliver.v1') { + return { error: 'unsupported envelope type' }; + } + if (typeof encryptedDeliveryContext !== 'string' || !encryptedDeliveryContext.trim()) { return { error: 'encryptedDeliveryContext is required' }; } const channelCode = normalizeChannel(channel); if (!channelCode) { - return { error: `unsupported channel '${channel}'` }; + return { error: 'unsupported channel' }; } const modeCode = normalizeMode(mode); if (!modeCode) { - return { error: `unsupported mode '${mode}'` }; + return { error: 'unsupported mode' }; + } + if (Object.hasOwn(payload, 'ttlSeconds')) { + if (!Number.isInteger(ttlSeconds) || ttlSeconds > 2147483647) { + return { error: 'invalid ttlSeconds' }; + } + if (ttlSeconds <= 0) { + return { error: 'ttlSeconds expired' }; + } } return { envelope: { type, tenantId, correlationId, channel: channelCode, mode: modeCode, ttlSeconds, encryptedDeliveryContext } }; } - // Reject oversized or structurally invalid JWEs before decoding or allocating buffers. const MAX_JWE_LENGTH = 16384; @@ -72,12 +81,9 @@ function readProtectedHeader(compactJwe) { return JSON.parse(Buffer.from(protectedSegment, 'base64url').toString('utf8')); } -// Imported once: a per-delivery RSA import would sit inside the response budget. let cachedKey; let cachedKeyPem; -// The setup script stores the key as base64 over the PEM so its newlines survive being carried as an -// app setting, so accept either form. function normalizePem(value) { const text = String(value || ''); if (text.includes('-----BEGIN')) return text; @@ -96,8 +102,6 @@ function loadPrivateKey(pem) { return cachedKey; } -// Decrypts the JWE compact serialization. Returns the protected header (for kid/alg logging) alongside -// the CyotDeliveryContext. async function decryptDeliveryContext(compactJwe, config = readConfig()) { assertWellFormedJwe(compactJwe); const header = readProtectedHeader(compactJwe); @@ -110,17 +114,11 @@ async function decryptDeliveryContext(compactJwe, config = readConfig()) { return { header, context: JSON.parse(Buffer.from(plaintext).toString('utf8')) }; } -// Left alone, TTS reads 641895 as "six hundred forty-one thousand...", which no user can type. -function spacePasscodeForVoice(message) { - return String(message || '').replace(/\b\d{4,8}\b/, (digits) => digits.split('').join(' ')); -} - -// The message is pre-rendered and already contains the passcode, so there is no separate code field. function contextToDispatch(context, envelope, messageId) { const channel = CHANNEL_BY_CODE[envelope.channel]; return { destination: context.phoneNumber, - message: channel === 'voice' ? spacePasscodeForVoice(context.message) : context.message, + message: context.message, channel, messageId, correlationId: envelope.correlationId, @@ -128,7 +126,6 @@ function contextToDispatch(context, envelope, messageId) { }; } - const OUTCOME = Object.freeze({ CONTINUE: 'Continue', FAIL: 'Fail', @@ -136,32 +133,8 @@ const OUTCOME = Object.freeze({ STEP_UP: 'StepUp', }); -const HTTP_STATUS = Object.freeze({ - OK: 200, - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - CONFLICT: 409, - TOO_MANY_REQUESTS: 429, - BAD_GATEWAY: 502, - GATEWAY_TIMEOUT: 504, -}); - -const RESPONSE_STATUS = Object.freeze({ - ACCEPTED: 'accepted', - FAILED: 'failed', - ERROR: 'error', -}); - -const DEFAULTS = Object.freeze({ - CHANNEL: 'sms', - ENDPOINT_TIMEOUT_MILLISECONDS: 1500, - CHANNELS: ['sms', 'voice'], -}); - const SECRET_CACHE_TIME_TO_LIVE_MILLISECONDS = 5 * 60 * 1000; // rotated secrets picked up within this window -// Onboarding a provider is a new file plus one line here — static, so a broken provider fails at load. const providerRegistry = new Map( [ require('./providers/infobip'), @@ -175,91 +148,113 @@ const providerRegistry = new Map( ); function getProvider(providerId) { - return providerId ? providerRegistry.get(String(providerId).toLowerCase()) || null : null; + return providerId ? providerRegistry.get(String(providerId).trim().toLowerCase()) || null : null; } -// One provider is active per deployment; the argument is a test override. -function resolveProvider(requestProvider) { - return getProvider(requestProvider || process.env.EPP_PROVIDER_NAME); -} - -// The manifest carries only the secret's name; the value is read just-in-time and never logged. let keyVaultSecretClient = null; +let keyVaultClientConfig; const secretCache = new Map(); -// The identity needs the Key Vault Secrets User role on the vault. -function getKeyVaultSecretClient() { - if (!keyVaultSecretClient) { - const credential = process.env.AZURE_CLIENT_ID - ? new ManagedIdentityCredential(process.env.AZURE_CLIENT_ID) +function getKeyVaultSecretClient(config) { + const cacheKey = JSON.stringify([config.keyVaultUrl, config.managedIdentityClientId]); + if (!keyVaultSecretClient || keyVaultClientConfig !== cacheKey) { + const credential = config.managedIdentityClientId + ? new ManagedIdentityCredential(config.managedIdentityClientId) : new ManagedIdentityCredential(); - keyVaultSecretClient = new SecretClient(process.env.KEY_VAULT_URL, credential); + keyVaultSecretClient = new SecretClient(config.keyVaultUrl, credential); + keyVaultClientConfig = cacheKey; } return keyVaultSecretClient; } -async function resolveSecretValue(keyVaultSecretName) { +async function resolveSecretValue(keyVaultSecretName, config) { if (!keyVaultSecretName) { return ''; } - const cachedSecret = secretCache.get(keyVaultSecretName); + const cacheKey = JSON.stringify([config.keyVaultUrl, config.managedIdentityClientId, keyVaultSecretName]); + const cachedSecret = secretCache.get(cacheKey); if (cachedSecret && cachedSecret.expiresAt > Date.now()) { return cachedSecret.value; } - const secretValue = (await getKeyVaultSecretClient().getSecret(keyVaultSecretName)).value || ''; + const secretValue = (await getKeyVaultSecretClient(config).getSecret(keyVaultSecretName)).value || ''; - secretCache.set(keyVaultSecretName, { + secretCache.set(cacheKey, { value: secretValue, expiresAt: Date.now() + SECRET_CACHE_TIME_TO_LIVE_MILLISECONDS, }); return secretValue; } -async function resolveProviderCredential(authConfiguration = {}, acquireProviderToken) { - if ((authConfiguration.mode || 'apiKey') === 'oauth2') { - // oauth2 is not wired end-to-end yet: with no injected acquireProviderToken it fails closed. - const bearerToken = typeof acquireProviderToken === 'function' ? await acquireProviderToken() : null; - return { mode: 'oauth2', token: bearerToken }; - } +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), + resolveSecretValue(authConfiguration.keyVaultSecretName, config), authConfiguration.identityKeyVaultSecretName - ? resolveSecretValue(authConfiguration.identityKeyVaultSecretName) + ? resolveSecretValue(authConfiguration.identityKeyVaultSecretName, config) : Promise.resolve(''), ]); return { mode: 'apiKey', secret, identity }; } -// A recognized status wins; an unknown status is fail-closed; only a status-less response trusts HTTP. +// Status mappings may restrict HTTP success, but cannot turn failed HTTP into Continue. function resolveOutcome(manifest, parsedResponse) { const responseMapping = manifest.responseMapping || {}; const providerStatusKey = parsedResponse.providerStatusName || parsedResponse.providerStatusCode; - if (providerStatusKey) { - return responseMapping[providerStatusKey] || responseMapping.default || OUTCOME.FAIL; - } - return parsedResponse.success ? OUTCOME.CONTINUE : (responseMapping.default || OUTCOME.FAIL); + const fallback = Object.hasOwn(responseMapping, 'default') + ? responseMapping.default || OUTCOME.FAIL : OUTCOME.FAIL; + const hasMapping = (typeof providerStatusKey === 'string' || typeof providerStatusKey === 'number') + && Object.hasOwn(responseMapping, providerStatusKey); + const outcome = providerStatusKey + ? (hasMapping ? responseMapping[providerStatusKey] || fallback : fallback) + : (parsedResponse.success ? OUTCOME.CONTINUE : fallback); + return outcome === OUTCOME.CONTINUE && !parsedResponse.success ? OUTCOME.FAIL : outcome; } function outcomeToHttpStatus(outcome, providerHttpStatus) { switch (outcome) { case OUTCOME.CONTINUE: - return HTTP_STATUS.OK; + return 200; case OUTCOME.BLOCK: - return HTTP_STATUS.FORBIDDEN; + return 403; case OUTCOME.STEP_UP: - return HTTP_STATUS.CONFLICT; + return 409; case OUTCOME.FAIL: - if (providerHttpStatus === HTTP_STATUS.TOO_MANY_REQUESTS) return HTTP_STATUS.TOO_MANY_REQUESTS; - if (providerHttpStatus === HTTP_STATUS.UNAUTHORIZED || providerHttpStatus === HTTP_STATUS.FORBIDDEN) return HTTP_STATUS.UNAUTHORIZED; - if (providerHttpStatus >= 400 && providerHttpStatus < 500) return HTTP_STATUS.BAD_REQUEST; - return HTTP_STATUS.BAD_GATEWAY; + if (providerHttpStatus === 429) return 429; + if (providerHttpStatus === 401 || providerHttpStatus === 403) return 401; + if (providerHttpStatus >= 400 && providerHttpStatus < 500) return 400; + return 502; default: - return HTTP_STATUS.BAD_GATEWAY; + return 502; } } +// 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(); @@ -270,58 +265,54 @@ async function fetchWithTimeout(providerRequest, timeoutMilliseconds) { }, timeoutMilliseconds); try { - return await fetch(providerRequest.url, { + const response = await fetch(providerRequest.url, { method: providerRequest.method || 'POST', headers: providerRequest.headers, body: providerRequest.body, signal: abortController.signal, + redirect: 'manual', // Never forward provider credentials to a redirect target. }); - } catch (error) { - throw new Error(timedOut ? `endpoint timeout after ${timeoutMilliseconds}ms` : error.message); + const responseText = await response.text(); + return { response, responseText }; + } catch { + const error = new Error('provider request failed'); + error.name = timedOut ? 'TimeoutError' : 'Error'; + throw error; } finally { clearTimeout(timeoutTimer); } } -const errorBody = (providerId, reason, requestId) => - ({ status: RESPONSE_STATUS.ERROR, provider: providerId, reason, requestId }); const failBody = (providerId, channel, reason, dispatch, requestId) => - ({ status: RESPONSE_STATUS.FAILED, outcome: OUTCOME.FAIL, provider: providerId, channel, reason, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }); + ({ status: 'failed', outcome: OUTCOME.FAIL, provider: providerId, channel, reason, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }); async function sendViaProvider(providerEntry, dispatch, options) { - const { shutter, context, requestId } = options; - const writeLog = (logMessage) => context && context.log(logMessage); - + const { requestId, config } = options; const { manifest, adapter } = providerEntry; const providerId = manifest.id; - const channel = (dispatch.channel || DEFAULTS.CHANNEL).toLowerCase(); + const channel = dispatch.channel === undefined ? 'sms' + : (typeof dispatch.channel === 'string' ? dispatch.channel.toLowerCase() : null); + + if (!['sms', 'voice'].includes(channel)) { + return { httpStatus: 400, body: { status: 'error', reason: 'unsupported channel', requestId } }; + } - if (!DEFAULTS.CHANNELS.includes(channel)) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} not supported`); - return { httpStatus: HTTP_STATUS.BAD_REQUEST, body: errorBody(providerId, `channel '${channel}' not supported`, requestId) }; + const endpointBaseUrl = config.providerEndpoint; + if (!isValidProviderUrl(endpointBaseUrl)) { + return { httpStatus: 502, body: failBody(providerId, channel, 'provider endpoint missing or invalid', dispatch, requestId) }; } - // Fail closed (502) if the credential is missing — this is our credential, not the caller's token. let credential = null; try { - credential = await resolveProviderCredential(manifest.auth, options.acquireProviderToken); - } catch (error) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} credential error=${error.message}`); + credential = await resolveProviderCredential(manifest.auth, config); + } catch { + // Configuration and secret lookup failures share a generic failure response. } - const identityRequired = credential && credential.mode === 'apiKey' && !!manifest.auth.identityKeyVaultSecretName; - const credentialUnavailable = !credential - || (credential.mode === 'oauth2' && !credential.token) - || (credential.mode === 'apiKey' && !credential.secret) + const identityRequired = !!manifest.auth?.identityKeyVaultSecretName; + const credentialUnavailable = !credential || !credential.secret || (identityRequired && !credential.identity); if (credentialUnavailable) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} provider credential unavailable`); - return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; - } - - const endpointBaseUrl = process.env.EPP_PROVIDER_ENDPOINT; - if (!endpointBaseUrl) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} endpoint not configured`); - return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider endpoint not configured', dispatch, requestId) }; + return { httpStatus: 502, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; } const providerRequest = adapter.buildRequest({ @@ -329,37 +320,29 @@ async function sendViaProvider(providerEntry, dispatch, options) { endpoint: endpointBaseUrl, dispatch, credential, - env: process.env, + env: config.env, }); - writeLog(`[DISPATCH] requestId=${requestId} provider=${providerId} channel=${channel} correlationId=${dispatch.correlationId} shutter=${!!shutter}`); - - if (shutter) { - writeLog(`[SHUTTER] requestId=${requestId} provider=${providerId} channel=${channel} processed but NOT sending`); - return { - httpStatus: HTTP_STATUS.OK, - body: { status: RESPONSE_STATUS.ACCEPTED, shutterProcessed: true, provider: providerId, channel, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }, - }; + if (!isValidProviderUrl(providerRequest.url)) { + return { httpStatus: 502, body: failBody(providerId, channel, 'provider request URL invalid', dispatch, requestId) }; } - const timeoutMilliseconds = Number(process.env.EPP_PROVIDER_TIMEOUT_MS) || DEFAULTS.ENDPOINT_TIMEOUT_MILLISECONDS; + const timeoutMilliseconds = parseProviderTimeout(config.providerTimeoutMs); let providerResponse; + let responseText; try { - providerResponse = await fetchWithTimeout(providerRequest, timeoutMilliseconds); + ({ response: providerResponse, responseText } = await fetchWithTimeout(providerRequest, timeoutMilliseconds)); } catch (error) { - const isTimeout = typeof error.message === 'string' && error.message.startsWith('endpoint timeout'); - const httpStatus = isTimeout ? HTTP_STATUS.GATEWAY_TIMEOUT : HTTP_STATUS.BAD_GATEWAY; - writeLog(`[${isTimeout ? 'DISPATCH_TIMEOUT' : 'DISPATCH_ERROR'}] requestId=${requestId} provider=${providerId} channel=${channel} reason=${error.message}`); - return { httpStatus, body: failBody(providerId, channel, error.message, dispatch, requestId) }; + const isTimeout = error.name === 'TimeoutError'; + const httpStatus = isTimeout ? 504 : 502; + return { httpStatus, body: failBody(providerId, channel, isTimeout ? 'provider request timed out' : 'provider request failed', dispatch, requestId) }; } - const responseText = await providerResponse.text(); let responseJson; try { responseJson = JSON.parse(responseText); } catch { - // Keep a non-JSON body raw so the adapter's parseResponse still runs. - responseJson = { raw: responseText }; + responseJson = {}; } const parsedResponse = adapter.parseResponse({ @@ -370,38 +353,29 @@ async function sendViaProvider(providerEntry, dispatch, options) { const outcome = resolveOutcome(manifest, parsedResponse); const httpStatus = outcomeToHttpStatus(outcome, parsedResponse.providerHttpStatus); - writeLog(`[DISPATCH_RESULT] requestId=${requestId} provider=${providerId} channel=${channel} outcome=${outcome} providerStatus=${parsedResponse.providerStatusName || parsedResponse.providerStatusCode || 'n/a'} httpStatus=${httpStatus} correlationId=${dispatch.correlationId}`); - return { httpStatus, body: { - status: outcome === OUTCOME.CONTINUE ? RESPONSE_STATUS.ACCEPTED : RESPONSE_STATUS.FAILED, + status: outcome === OUTCOME.CONTINUE ? 'accepted' : 'failed', outcome, provider: providerId, channel, messageId: dispatch.messageId, correlationId: dispatch.correlationId, - providerMessageId: parsedResponse.providerMessageId || null, - providerStatus: parsedResponse.providerStatusName || parsedResponse.providerStatusCode || null, - providerStatusDescription: parsedResponse.providerStatusDescription || null, requestId, }, }; } -async function dispatchOtp(dispatch, options) { - const { requestProvider, context, requestId } = options; - const writeLog = (logMessage) => context && context.log(logMessage); - - const providerEntry = resolveProvider(requestProvider); +async function dispatchOtp(dispatch, { config = readConfig(), requestId } = {}) { + const providerEntry = getProvider(config.providerName); if (!providerEntry) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} unknown provider=${requestProvider || 'n/a'}`); return { - httpStatus: HTTP_STATUS.BAD_REQUEST, - body: { status: RESPONSE_STATUS.ERROR, reason: 'unknown provider', requestId }, + httpStatus: 400, + body: { status: 'error', reason: 'unknown provider', requestId }, }; } - return sendViaProvider(providerEntry, dispatch, options); + return sendViaProvider(providerEntry, dispatch, { config, requestId }); } module.exports = { @@ -413,4 +387,6 @@ module.exports = { getProvider, resolveOutcome, outcomeToHttpStatus, + parseProviderTimeout, + isValidProviderUrl, }; diff --git a/javascript/src/functions/providers/infobip.js b/javascript/src/functions/providers/infobip.js index ad7badc..ee3aa2b 100644 --- a/javascript/src/functions/providers/infobip.js +++ b/javascript/src/functions/providers/infobip.js @@ -4,7 +4,7 @@ 'use strict'; -// Infobip: SMS /sms/3/messages, voice /tts/3/advanced (unverified). Auth: App API key, or Bearer in oauth2 mode. +// Voice integration is unverified; confirm the request format before production use. const manifest = { id: 'infobip', @@ -23,9 +23,8 @@ const manifest = { function buildRequest({ channel, endpoint, dispatch, credential, env }) { const base = endpoint; const senderId = env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify'; - const authorization = credential.mode === 'oauth2' ? `Bearer ${credential.token}` : `App ${credential.secret}`; const headers = { - Authorization: authorization, + Authorization: `App ${credential.secret}`, 'Content-Type': 'application/json', Accept: 'application/json', }; @@ -61,7 +60,6 @@ function parseResponse({ httpStatus, ok, json }) { providerHttpStatus: httpStatus, providerMessageId: (firstMessage && firstMessage.messageId) || null, providerStatusName: (status.groupName || status.name || '').toUpperCase() || null, - providerStatusDescription: status.description || null, }; } diff --git a/javascript/src/functions/providers/sinch.js b/javascript/src/functions/providers/sinch.js index a3c7bd7..6376dfa 100644 --- a/javascript/src/functions/providers/sinch.js +++ b/javascript/src/functions/providers/sinch.js @@ -4,8 +4,7 @@ 'use strict'; -// Sinch: SMS via XMS batches, voice via the Calling TTS callout. XMS returns a batch id, not a final -// delivery status — that arrives asynchronously by callback. +// A batch identifier indicates acceptance, not final delivery; delivery status arrives by callback. const manifest = { id: 'sinch', @@ -21,15 +20,14 @@ const manifest = { }; function buildRequest({ channel, endpoint, dispatch, credential, env }) { - const bearerToken = credential.mode === 'oauth2' ? credential.token : credential.secret; const headers = { - Authorization: `Bearer ${bearerToken}`, + Authorization: `Bearer ${credential.secret}`, 'Content-Type': 'application/json', Accept: 'application/json', }; if (channel === 'voice') { - // Sinch Voice uses its own host and normally app-signed auth, not the XMS token — verify. + // Voice uses a separate host; verify that its authentication accepts the configured credential. const voiceBase = env.SINCH_VOICE_ENDPOINT || 'https://calling.api.sinch.com'; const body = { method: 'ttsCallout', @@ -61,7 +59,6 @@ function parseResponse({ httpStatus, ok, json }) { providerHttpStatus: httpStatus, providerMessageId: typeof messageOrCallId === 'string' ? messageOrCallId : (messageOrCallId && messageOrCallId.href) || null, providerStatusName: ok ? 'Dispatched' : (json && (json.text || json.status)) || null, - providerStatusDescription: (json && (json.text || json.detailedStatus)) || null, }; } diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index 2a79465..ed4886b 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -4,9 +4,6 @@ 'use strict'; -// Soprano Connect (MEMS): POST {base}/messages/{sms|voice}, base https:///cgpapi. -// Auth: X-MEMS-API-ID + X-MEMS-API-Key, or a Bearer JWT. Verified live (HTTP 201, ENROUTE). - const manifest = { id: 'soprano', auth: { @@ -23,65 +20,42 @@ const manifest = { QUEUED: 'Continue', FAILED: 'Fail', REJECTED: 'Fail', + FILTERED: 'Fail', BLOCKED: 'Block', default: 'Fail', }, }; -function buildRequest({ channel, endpoint, dispatch, credential, env }) { - const base = endpoint; - const messageType = channel === 'voice' ? 'voice' : 'sms'; - - const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }; - if (credential.mode === 'oauth2') { - headers.Authorization = `Bearer ${credential.token}`; - } else { - headers['X-MEMS-API-ID'] = credential.identity; - headers['X-MEMS-API-Key'] = credential.secret; - } - +function buildRequest({ channel, endpoint, dispatch, credential }) { + let base = endpoint; + while (base.endsWith('/')) base = base.slice(0, -1); + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-MEMS-API-ID': credential.identity, + 'X-MEMS-API-Key': credential.secret, + }; + let destination = String(dispatch.destination || ''); + while (destination.startsWith('+')) destination = destination.slice(1); const body = { - messageType, - destination: dispatch.destination, text: dispatch.message, - clientReference: dispatch.correlationId || dispatch.messageId, + destination, + messageTypes: [channel === 'voice' ? 'voice' : 'sms'], + correlationId: dispatch.correlationId || dispatch.messageId, + shutterMode: false, }; - // Soprano wants a provisioned (numeric) source endpoint; a non-numeric name goes as free-text source. - const account = env.EPP_PROVIDER_ACCOUNT_NAME; - if (account && /^\d+$/.test(account)) { - body.endpoints = [{ type: Number(env.SOPRANO_SOURCE_TYPE || 1), id: Number(account) }]; - } else if (account) { - body.source = account; - } - // `language` must be a full voice code (e.g. en-US), not a bare `en`. - if (messageType === 'voice') { - const voiceLanguage = env.SOPRANO_VOICE_LANGUAGE - || (dispatch.locale && dispatch.locale.includes('-') ? dispatch.locale : 'en-US'); - delete body.text; - body.voice = { - text2voice: { - beforePasswordText: dispatch.message || '', - password: '', - afterPasswordText: '', - language: voiceLanguage, - gender: Number(env.SOPRANO_VOICE_GENDER || 1), - loop: 1, - }, - }; - } - - return { url: `${base}/messages/${messageType}`, method: 'POST', headers, body: JSON.stringify(body) }; + return { url: `${base}/messages/omnimsg`, method: 'POST', headers, body: JSON.stringify(body) }; } function parseResponse({ httpStatus, ok, json }) { const payload = (Array.isArray(json) ? json[0] : json) || {}; - const status = (payload.status || payload.state || '').toString().toUpperCase() || (ok ? 'SUBMITTED' : null); + const value = payload.status ?? payload.state; + const status = typeof value === 'string' && value ? value.toUpperCase() : 'UNKNOWN'; return { success: ok, providerHttpStatus: httpStatus, providerMessageId: (payload.id != null ? String(payload.id) : null) || payload.messageId || null, providerStatusName: status, - providerStatusDescription: payload.errorDescription || payload.statusText || payload.description || null, }; } diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index 67f557d..705953e 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -4,9 +4,6 @@ 'use strict'; -// Telesign: SMS /v1/messaging, voice /v1/voice, form-urlencoded. -// Auth: HTTP Basic (customer_id:api_key) from Key Vault, or Bearer in oauth2 mode. - const manifest = { id: 'telesign', auth: { @@ -32,10 +29,7 @@ const manifest = { function buildRequest({ channel, endpoint, dispatch, credential, env }) { const base = endpoint; const contentType = 'application/x-www-form-urlencoded'; - - const authorization = credential.mode === 'oauth2' - ? `Bearer ${credential.token}` - : `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`; + const authorization = `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`; let path; let params; @@ -80,7 +74,6 @@ function parseResponse({ httpStatus, ok, json }) { providerMessageId: (json && json.reference_id) || null, providerStatusCode: status.code != null ? String(status.code) : null, providerStatusName: null, - providerStatusDescription: status.description || null, }; } diff --git a/javascript/src/functions/security.js b/javascript/src/functions/security.js deleted file mode 100644 index 9f2d6df..0000000 --- a/javascript/src/functions/security.js +++ /dev/null @@ -1,72 +0,0 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// - -'use strict'; - -// Optional inbound bearer-token validation: anonymous unless EPP_REQUIRE_AUTH=true, which validates -// a Microsoft JWT (iss/aud/exp/RS256). Easy Auth is the primary gate; this is the backstop. - -const jwksByTenant = new Map(); - -function getJwks(issuerTenantId) { - if (!jwksByTenant.has(issuerTenantId)) { - const { createRemoteJWKSet } = require('jose'); - jwksByTenant.set( - issuerTenantId, - createRemoteJWKSet(new URL(`https://login.microsoftonline.com/${issuerTenantId}/discovery/v2.0/keys`)), - ); - } - return jwksByTenant.get(issuerTenantId); -} - -// azp is the v2 caller claim, appid the v1 one. -function isExpectedCaller(payload, expectedClientId) { - if (!expectedClientId) return true; - return (payload.azp || payload.appid) === expectedClientId; -} - -async function validateToken(request, context, requestId) { - if (String(process.env.EPP_REQUIRE_AUTH || 'false').toLowerCase() !== 'true') { - return { ok: true, skipped: true }; - } - - const audience = process.env.EPP_EXPECTED_AUDIENCE; - const tenantId = process.env.EPP_TENANT_ID; - if (!audience || !tenantId) { - return { ok: false, reason: 'EPP_REQUIRE_AUTH is set but EPP_EXPECTED_AUDIENCE / EPP_TENANT_ID are missing' }; - } - - const authorizationHeader = (request.headers.get('authorization') || '').trim(); - const bearerToken = authorizationHeader.slice(0, 7).toLowerCase() === 'bearer ' - ? authorizationHeader.slice(7).trim() - : ''; - if (!bearerToken) return { ok: false, reason: 'missing bearer token' }; - - try { - const { jwtVerify } = require('jose'); - // Accept both the v2 and v1 issuer forms unless EPP_EXPECTED_ISSUER pins one. - const issuers = process.env.EPP_EXPECTED_ISSUER - ? [process.env.EPP_EXPECTED_ISSUER] - : [ - `https://login.microsoftonline.com/${tenantId}/v2.0`, - `https://sts.windows.net/${tenantId}/`, - ]; - const { payload } = await jwtVerify(bearerToken, getJwks(tenantId), { - audience, - issuer: issuers, - algorithms: ['RS256'], - }); - - if (!isExpectedCaller(payload, process.env.EPP_EXPECTED_CLIENT_ID)) { - context.log(`[AUTH_FAIL] requestId=${requestId} reason=unexpected caller appid=${payload.azp || payload.appid || 'none'}`); - return { ok: false, reason: 'unexpected caller' }; - } - return { ok: true }; - } catch (error) { - context.log(`[AUTH_FAIL] requestId=${requestId} reason=${error.message}`); - return { ok: false, reason: 'token validation failed' }; - } -} - -module.exports = { validateToken, isExpectedCaller }; diff --git a/javascript/test/auth.test.js b/javascript/test/auth.test.js deleted file mode 100644 index b5e80c2..0000000 --- a/javascript/test/auth.test.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict'; - -const { test, afterEach } = require('node:test'); -const assert = require('node:assert'); -const { validateToken, isExpectedCaller } = require('../src/functions/security'); - -const ctx = { log() {} }; -const reqWith = (headers = {}) => ({ headers: { get: (k) => headers[k.toLowerCase()] || null } }); - -afterEach(() => { - delete process.env.EPP_REQUIRE_AUTH; - delete process.env.EPP_EXPECTED_AUDIENCE; - delete process.env.EPP_TENANT_ID; - delete process.env.EPP_EXPECTED_CLIENT_ID; -}); - -test('skips validation when REQUIRE_AUTH is not true', async () => { - const r = await validateToken(reqWith(), ctx, 'r'); - assert.equal(r.ok, true); - assert.equal(r.skipped, true); -}); - -test('fails when EPP_REQUIRE_AUTH=true but audience/tenant are missing', async () => { - process.env.EPP_REQUIRE_AUTH = 'true'; - const r = await validateToken(reqWith(), ctx, 'r'); - assert.equal(r.ok, false); - assert.match(r.reason, /EPP_EXPECTED_AUDIENCE|EPP_TENANT_ID/); -}); - -test('fails when the bearer token is missing', async () => { - process.env.EPP_REQUIRE_AUTH = 'true'; - process.env.EPP_EXPECTED_AUDIENCE = 'aud'; - process.env.EPP_TENANT_ID = 'tid'; - const r = await validateToken(reqWith(), ctx, 'r'); - assert.equal(r.ok, false); - assert.equal(r.reason, 'missing bearer token'); -}); - -test('fails (generic reason) on an invalid token', async () => { - process.env.EPP_REQUIRE_AUTH = 'true'; - process.env.EPP_EXPECTED_AUDIENCE = 'aud'; - process.env.EPP_TENANT_ID = 'tid'; - const r = await validateToken(reqWith({ authorization: 'Bearer not-a-jwt' }), ctx, 'r'); - assert.equal(r.ok, false); - assert.equal(r.reason, 'token validation failed'); -}); - -// Easy Auth normally rejects the wrong caller at the platform; these cover the standalone path. -for (const [callerAppId, expected, allowed] of [ - ['anything', '', true], // unpinned client id accepts any caller - ['expected-app', 'expected-app', true], - ['some-other-app', 'expected-app', false], - [undefined, 'expected-app', false], // token carrying no caller claim -]) { - test(`caller check: appid=${callerAppId} expected=${expected || '(unpinned)'} -> ${allowed}`, () => { - assert.equal(isExpectedCaller({ azp: callerAppId }, expected), allowed); - }); -} - -test('caller check: accepts the v1 appid claim', () => { - assert.equal(isExpectedCaller({ appid: 'expected-app' }, 'expected-app'), true); -}); diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index fff3223..61ed9f8 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -1,193 +1,145 @@ 'use strict'; -// Integration tests for the dispatch pipeline with a mocked provider fetch and a mocked Key Vault. - -const { test, beforeEach, mock } = require('node:test'); -const assert = require('node:assert'); - -// Non-secret provider config (app settings, not secrets) — set before requiring the modules. -process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; -process.env.SINCH_SERVICE_PLAN_ID = 'sp'; -process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; - -// Provider secrets come from Key Vault via managed identity in production; mock getSecret here. -const providerSecrets = { - 'infobip-api-key': 'ib', - 'telesign-api-key': 'ts', - 'telesign-customer-id': 'cust', - 'sinch-api-token': 'st', - 'soprano-api-key': 'sp', - 'soprano-api-id': 'sp-id', -}; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); const { SecretClient } = require('@azure/keyvault-secrets'); -mock.method(SecretClient.prototype, 'getSecret', async (name) => ({ value: providerSecrets[name] })); - -const { dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus } = require('../src/functions/dispatch'); - -let resp; -let sent; -global.fetch = async (url, opts) => { - sent = { url, opts }; - if (resp === 'THROW') throw new Error('neterr'); - if (resp === 'TIMEOUT') throw new Error('endpoint timeout after 1500ms'); - return { ok: resp.ok, status: resp.status, text: async () => JSON.stringify(resp.body) }; -}; - -const ctx = { log() {} }; -let n = 0; -const uniqueDest = () => '+1555' + String(1000000 + n++).slice(-7); -const disp = (o = {}) => ({ destination: uniqueDest(), message: 'Your code is 918273', channel: 'sms', messageId: 'm', correlationId: 'c' + Math.random(), ...o }); - -beforeEach(() => { - resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'DELIVERED' } }] } }; +const { readConfig } = require('../src/functions/config'); +const { + dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus, + parseEnvelope, parseProviderTimeout, isValidProviderUrl, +} = require('../src/functions/dispatch'); +const dispatch = { destination: '+15551234567', message: ' Your code is 918273.\n', + channel: 'sms', messageId: 'message-id', correlationId: 'correlation-id' }; +const input = { channel: 'sms', endpoint: 'https://provider.example', dispatch, + credential: { mode: 'apiKey', identity: 'id', secret: 'key' }, env: { SINCH_SERVICE_PLAN_ID: 'plan' } }; +const envelope = (overrides = {}) => ({ type: 'microsoft.mfa.otpDeliver.v1', channel: 1, mode: 1, + encryptedDeliveryContext: 'a.b.c.d.e', ...overrides }); + +test('config uses the deployment provider, with no hardcoded fallback', async (t) => { + const env = { EPP_PROVIDER_NAME: ' SiNcH ', + EPP_PROVIDER_TIMEOUT_MS: ' 0012 ', SINCH_SERVICE_PLAN_ID: 'custom-plan', + EPP_PROVIDER_ENDPOINT: input.endpoint, KEY_VAULT_URL: 'https://config-test.vault.azure.net' }; + const getSecret = t.mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'fixture-key' })); + const fetchMock = t.mock.method(global, 'fetch', async () => ({ ok: true, status: 200, + text: async () => JSON.stringify({ id: 'batch-id' }) })); + const config = readConfig(env); + assert.deepEqual([config.providerName, config.providerTimeoutMs], ['sinch', ' 0012 ']); + assert.equal(config.env, env); + assert.equal(readConfig({}).providerName, ''); + for (const providerName of ['', 'unknown']) { + assert.equal((await dispatchOtp(dispatch, { config: { ...config, providerName } })).httpStatus, 400); + } + assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); + const result = await dispatchOtp({ ...dispatch, provider: 'unknown' }, { config }); + assert.deepEqual([result.httpStatus, result.body.provider, result.body.outcome], [200, 'sinch', 'Continue']); + assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [1, 1]); + const [url, init] = fetchMock.mock.calls[0].arguments; + assert.equal(url, `${input.endpoint}/xms/v1/custom-plan/batches`); + assert.equal(JSON.parse(init.body).body, dispatch.message); }); -// A "success" response body shaped the way each provider's parseResponse expects, so each yields a -// status that maps to Continue (unknown statuses now fail closed — see resolveOutcome). -const successBody = { - infobip: { messages: [{ status: { name: 'DELIVERED' } }] }, - telesign: { status: { code: 290 } }, - sinch: { id: 'batch-1' }, - soprano: { status: 'DELIVERED' }, -}; - -for (const prov of ['infobip', 'telesign', 'sinch', 'soprano']) { - for (const ch of ['sms', 'voice']) { - test(`${prov}/${ch}: 200, message sent in body, https, provider auth scheme`, async () => { - resp = { ok: true, status: 200, body: successBody[prov] }; - const r = await dispatchOtp(disp({ channel: ch }), { requestProvider: prov, context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 200); - assert.match(sent.url, /^https:\/\//); - assert.ok(sent.opts.body.includes('918273'), 'rendered message missing from body'); - const providerAuth = sent.opts.headers.Authorization || sent.opts.headers['X-MEMS-API-Key']; - assert.ok(providerAuth, 'provider auth header missing'); - if (sent.opts.headers.Authorization) { - assert.match(sent.opts.headers.Authorization, /Bearer|App|Basic/); - } - }); +test('envelope TTL boundaries and routing reject coercion', () => { + assert.ok(parseEnvelope(envelope()).envelope); + assert.ok(parseEnvelope(envelope({ ttlSeconds: 2147483647 })).envelope); + for (const ttlSeconds of [-1, 0, '60', null, true, 1.5, 2147483648]) { + assert.ok(parseEnvelope(envelope({ ttlSeconds })).error, String(ttlSeconds)); } -} - -// Outcome + HTTP mapping is pure, so it is asserted directly here instead of once per case through -// the whole dispatch pipeline (mirrors the .NET and Python contract tests). -test('outcome mapping and HTTP status', () => { - const infobip = getProvider('infobip').manifest; - const soprano = getProvider('soprano').manifest; - const telesign = getProvider('telesign').manifest; - - assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: 'DELIVERED' }), 'Continue'); - assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: 'REJECTED' }), 'Fail'); - assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: 'WATWAT' }), 'Fail'); - assert.equal(resolveOutcome(soprano, { success: true, providerStatusName: 'BLOCKED' }), 'Block'); - assert.equal(resolveOutcome(telesign, { success: true, providerStatusCode: '100' }), 'Continue'); - - assert.equal(outcomeToHttpStatus('Continue', 200), 200); - assert.equal(outcomeToHttpStatus('Block', 200), 403); - assert.equal(outcomeToHttpStatus('StepUp', 200), 409); - assert.equal(outcomeToHttpStatus('Fail', 429), 429); - assert.equal(outcomeToHttpStatus('Fail', 403), 401); - assert.equal(outcomeToHttpStatus('Fail', 422), 400); - assert.equal(outcomeToHttpStatus('Fail', 500), 502); + assert.equal(parseEnvelope(envelope({ channel: '1' })).error, 'unsupported channel'); + assert.equal(parseEnvelope(envelope({ mode: true })).error, 'unsupported mode'); }); -test('endpoint timeout maps to 504', async () => { - resp = 'TIMEOUT'; - const r = await dispatchOtp(disp(), { requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 504); - assert.equal(r.body.outcome, 'Fail'); +test('provider URLs and timeouts retain representative safety boundaries', () => { + for (const url of ['http://provider.example', 'https://@provider.example', 'https://provider.example#', + 'https://provider.example:0', 'https://provider.example:-1', 'https://provider.example:65536']) { + assert.equal(isValidProviderUrl(url), false, url); + } + assert.equal(isValidProviderUrl('https://provider.example:65535/path'), true); + for (const value of [null, '0', '-1', '1e3']) { + assert.equal(parseProviderTimeout(value), 1500); + } + assert.equal(parseProviderTimeout(' 0012 '), 12); + assert.equal(parseProviderTimeout('9999'), 2500); }); -test('network error (non-timeout) maps to 502', async () => { - resp = 'THROW'; - const r = await dispatchOtp(disp(), { requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 502); - assert.equal(r.body.outcome, 'Fail'); +test('omnimsg uses API ID/key headers and a constant false shutterMode wire field', () => { + const request = getProvider('soprano').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 }); }); -test('unknown provider status fails closed even on HTTP 200 (§15)', async () => { - resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'WATWATWAT' } }] } }; - const r = await dispatchOtp(disp(), { requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.body.outcome, 'Fail'); - assert.equal(r.body.status, 'failed'); +test('App authentication uses JSON SMS content', () => { + const request = getProvider('infobip').adapter.buildRequest(input); + assert.equal(request.url, 'https://provider.example/sms/3/messages'); + assert.equal(request.headers.Authorization, 'App key'); + assert.equal(request.headers['Content-Type'], 'application/json'); + assert.equal(JSON.parse(request.body).messages[0].content.text, dispatch.message); }); -// The code and phone necessarily appear in the outbound provider request — that is the delivery. -test('the code and phone never reach the logs or the response body', async () => { - const logs = []; - resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'DELIVERED' }, messageId: 'x' }] } }; - const r = await dispatchOtp( - { destination: '+15551234567', message: 'Your code is 918273', channel: 'sms', messageId: 'm', correlationId: 'c' }, - { requestProvider: 'infobip', context: { log: (m) => logs.push(String(m)) }, requestId: 'r' }, - ); - - assert.equal(r.httpStatus, 200); - assert.ok(sent.opts.body.includes('918273'), 'the rendered message IS sent to the provider'); - for (const line of logs) { - assert.ok(!line.includes('918273'), `code leaked in a log line: ${line}`); - assert.ok(!line.includes('5551234567'), `phone leaked in a log line: ${line}`); - } - const body = JSON.stringify(r.body); - assert.ok(!body.includes('918273'), 'code leaked in response body'); - assert.ok(!body.includes('5551234567'), 'phone leaked in response body'); +test('Basic authentication uses form-encoded SMS content', () => { + 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('apiKey mode fails closed when the secret is missing (502)', async () => { - const manifest = getProvider('soprano').manifest; - const saved = JSON.parse(JSON.stringify(manifest.auth)); - manifest.auth = { mode: 'apiKey', keyVaultSecretName: '__missing_secret__' }; - try { - const r = await dispatchOtp(disp(), { requestProvider: 'soprano', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 502); - assert.equal(r.body.reason, 'provider credential unavailable'); - } finally { - manifest.auth = saved; - } +test('static Bearer authentication uses the service-plan SMS route', () => { + const request = getProvider('sinch').adapter.buildRequest(input); + assert.equal(request.url, 'https://provider.example/xms/v1/plan/batches'); + assert.equal(request.headers.Authorization, 'Bearer key'); + assert.equal(request.headers['Content-Type'], 'application/json'); + assert.equal(JSON.parse(request.body).body, dispatch.message); }); -test('shutter returns 200 without sending', async () => { - let calls = 0; - const orig = global.fetch; - global.fetch = async (...a) => { calls++; return orig(...a); }; - try { - const r = await dispatchOtp(disp(), { requestProvider: 'infobip', shutter: true, context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 200); - assert.equal(r.body.shutterProcessed, true); - assert.equal(calls, 0); - } finally { - global.fetch = orig; +test('response parsing and HTTP mapping fail closed, including malformed status/state', () => { + const { manifest, adapter } = getProvider('soprano'); + const mapping = { ...manifest, responseMapping: { ...manifest.responseMapping, CHALLENGE: 'StepUp' } }; + for (const [json, upstream, expected, status] of [ + [{ status: 'ENROUTE' }, 201, 'Continue', 200], + [{ status: 'UNKNOWN' }, 200, 'Fail', 502], + [{ status: 'FILTERED' }, 200, 'Fail', 502], + [{ status: false, state: 'ACCEPTED' }, 200, 'Fail', 502], + [{ status: 123, state: 'ACCEPTED' }, 200, 'Fail', 502], + [{ state: false }, 200, 'Fail', 502], + [{ status: 'ENROUTE' }, 500, 'Fail', 502], + [{ status: 'BLOCKED' }, 500, 'Block', 403], + [{ status: 'CHALLENGE' }, 500, 'StepUp', 409], + ]) { + const parsed = adapter.parseResponse({ json, httpStatus: upstream, ok: upstream < 300 }); + const outcome = resolveOutcome(mapping, parsed); + assert.deepEqual([outcome, outcomeToHttpStatus(outcome, upstream)], [expected, status], JSON.stringify(json)); } }); -test('unknown provider is rejected (400)', async () => { - const r = await dispatchOtp(disp(), { requestProvider: 'nope', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 400); -}); - -test('oauth2 mode uses a Bearer token and fails closed without one', async () => { - const manifest = getProvider('sinch').manifest; - const saved = JSON.parse(JSON.stringify(manifest.auth)); - manifest.auth.mode = 'oauth2'; - try { - await dispatchOtp(disp(), { requestProvider: 'sinch', context: ctx, requestId: 'r', acquireProviderToken: async () => 'TKN' }); - assert.equal(sent.opts.headers.Authorization, 'Bearer TKN'); - - const noToken = await dispatchOtp(disp(), { requestProvider: 'sinch', context: ctx, requestId: 'r' }); - assert.equal(noToken.httpStatus, 502); // credential unavailable → 502, not 401 - } finally { - manifest.auth = saved; +test('missing key/identity and an unsafe final voice URL make zero HTTP calls', async (t) => { + const settings = { KEY_VAULT_URL: 'https://unit-test.vault.azure.net', + EPP_PROVIDER_ENDPOINT: input.endpoint, SINCH_VOICE_ENDPOINT: 'http://unsafe.example' }; + const getSecret = t.mock.method(SecretClient.prototype, 'getSecret', async (name) => ({ + value: ['soprano-api-id', 'telesign-api-key'].includes(name) ? '' : 'fixture-key', + })); + const fetchMock = t.mock.method(global, 'fetch', () => assert.fail('unexpected HTTP')); + for (const [providerName, channel, reason] of [ + ['soprano', 'sms', 'provider credential unavailable'], + ['telesign', 'sms', 'provider credential unavailable'], + ['sinch', 'voice', 'provider request URL invalid'], + ]) { + const config = readConfig({ ...settings, EPP_PROVIDER_NAME: providerName }); + const result = await dispatchOtp({ ...dispatch, channel }, { config, requestId: 'request-id' }); + assert.deepEqual([result.httpStatus, result.body.reason], [502, reason]); } -}); - -test('apiKey provider that needs an identity fails closed when the identity secret is missing (502)', async () => { - const manifest = getProvider('telesign').manifest; - const saved = JSON.parse(JSON.stringify(manifest.auth)); - manifest.auth.identityKeyVaultSecretName = '__missing_identity__'; - try { - const r = await dispatchOtp(disp(), { requestProvider: 'telesign', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 502); - assert.equal(r.body.reason, 'provider credential unavailable'); - } finally { - manifest.auth = saved; + const config = readConfig({ ...settings, EPP_PROVIDER_NAME: 'sinch' }); + const calls = getSecret.mock.callCount(); + for (const override of [{}, { managedIdentityClientId: '11111111-2222-4333-8444-555555555555' }, + { keyVaultUrl: 'https://other-test.vault.azure.net' }]) { + const nextConfig = { ...config, ...override }; + await dispatchOtp({ ...dispatch, channel: 'voice' }, { config: nextConfig }); + assert.equal(getSecret.mock.calls.at(-1).this.vaultUrl, nextConfig.keyVaultUrl); } + assert.equal(getSecret.mock.callCount(), calls + 2); + assert.equal(new Set(getSecret.mock.calls.map((call) => call.this)).size, 3); + assert.equal(fetchMock.mock.callCount(), 0); }); - diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 6372200..0d79552 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -1,163 +1,217 @@ 'use strict'; -// Tests for the SendOtp HTTP handler — the SAS → EPP envelope: validation, JWE decryption round-trip, -// the happy path (nonce echo), Evaluation mode, and auth rejection. Handlers are captured by stubbing -// @azure/functions; the JWE is encrypted here with a throwaway RSA key that the handler decrypts via -// EPP_DECRYPTION_KEY_PEM. - -const { test, mock } = require('node:test'); -const assert = require('node:assert'); -const crypto = require('crypto'); -const Module = require('module'); +const { test, beforeEach, afterEach, mock } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const Module = require('node:module'); const { CompactEncrypt } = require('jose'); - -// Throwaway RSA keypair: the handler decrypts with the private PEM from the environment. -const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); -process.env.EPP_DECRYPTION_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); -process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; -process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; -process.env.EPP_PROVIDER_NAME = 'infobip'; - const { SecretClient } = require('@azure/keyvault-secrets'); -mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'ib' })); +const fixtures = require('../../tests/fixtures/contract.json'); -// Capture the handlers SendOtp registers via app.http(...) by stubbing @azure/functions during require. -const handlers = {}; +// Capture the real handler; keys stay in memory and all external I/O is mocked. +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +let handler; const originalLoad = Module._load; -Module._load = function (request, parent, isMain) { - if (request === '@azure/functions') { - return { app: { http: (name, opts) => { handlers[name] = opts.handler; } } }; +const registration = mock.method(Module, '_load', function (name, ...args) { + if (name === '@azure/functions') { + return { app: { http: (_name, options) => { handler = options.handler; } } }; } - return originalLoad.apply(this, arguments); -}; -require('../src/functions/SendOtp'); -Module._load = originalLoad; - -// The handler answers before the provider call finishes, so tests await the send it kicked off. -const { whenDelivered } = require('../src/functions/SendOtp'); - -const ctx = { log() {} }; - -const makeReq = (body, headers = {}) => ({ - method: 'POST', - url: 'http://localhost/api/SendOtp', - headers: { get: (k) => headers[String(k).toLowerCase()] ?? null }, - text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + return originalLoad.call(this, name, ...args); }); - -async function encryptContext(context, kid = 'test-key') { - return new CompactEncrypt(Buffer.from(JSON.stringify(context))) - .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', kid }) - .encrypt(publicKey); +try { + require('../src/functions/SendOtp'); +} finally { + registration.mock.restore(); } -const sampleContext = () => ({ - nonce: 'nonce-abc', - phoneNumber: '+14255551234', - locale: 'en-US', - message: 'Your code is 1 2 3 4 5 6', +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']; +let savedEnv; +let fetchMock; +let getSecret; +let logs; +let warnings; +beforeEach(() => { + savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + for (const key of envKeys) delete process.env[key]; + Object.assign(process.env, { EPP_LOG_PLAINTEXT: 'true', + EPP_DECRYPTION_KEY_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }), + KEY_VAULT_URL: 'https://unit-test.vault.azure.net', EPP_PROVIDER_NAME: 'soprano', + EPP_PROVIDER_ENDPOINT: 'https://provider.example/cgpapi/' }); + getSecret = mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'PRIVATE-API-KEY' })); + fetchMock = mock.method(global, 'fetch', async () => ({ ok: true, status: 201, + text: async () => JSON.stringify({ status: 'ENROUTE', id: 'PRIVATE-ID', description: 'PRIVATE-STATUS' }) })); +}); +afterEach(() => { + mock.restoreAll(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } }); -async function makeEnvelope(overrides = {}, context = sampleContext()) { - return { - type: 'microsoft.mfa.otpDeliver.v1', - tenantId: 'tenant-1', - correlationId: 'corr-1', - channel: 1, - mode: 1, - ttlSeconds: 60, - encryptedDeliveryContext: await encryptContext(context), - ...overrides, - }; +const delivery = { nonce: 'PRIVATE-NONCE', phoneNumber: '+15551234567', + message: ' PRIVATE-MESSAGE 918273.\n', locale: 'PRIVATE-LOCALE', riskContext: { detail: 'PRIVATE-RISK' } }; +async function envelope(overrides = {}, context = delivery, header = {}) { + const encryptedDeliveryContext = await new CompactEncrypt(Buffer.from(JSON.stringify(context))) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', kid: 'PRIVATE-KID', ...header }) + .encrypt(publicKey); + return { type: 'microsoft.mfa.otpDeliver.v1', channel: 1, mode: 1, ttlSeconds: 60, + correlationId: 'correlation-id', encryptedDeliveryContext, ...overrides }; } - -let sent; -global.fetch = async (url, opts) => { - sent = { url, opts }; - return { - ok: true, - status: 200, - text: async () => JSON.stringify({ messages: [{ status: { groupName: 'PENDING' }, messageId: 'x' }] }), - }; +const invoke = (body, headers = {}) => { + logs = []; + warnings = []; + return handler({ headers: { get: (name) => headers[name.toLowerCase()] || null }, + text: async () => typeof body === 'string' ? body : JSON.stringify(body) }, + { log: (value) => logs.push(value), warn: (...values) => warnings.push(values) }); }; +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/); +} -test('SendOtp: invalid JSON body -> 400', async () => { - const r = await handlers.SendOtp(makeReq('{ not json'), ctx); - assert.equal(r.status, 400); - assert.equal(r.jsonBody.error, 'bad_request'); -}); - -test('SendOtp: missing encryptedDeliveryContext -> 400', async () => { - const r = await handlers.SendOtp(makeReq({ type: 'v1', channel: 1, mode: 1 }), ctx); - assert.equal(r.status, 400); - assert.match(r.jsonBody.reason, /encryptedDeliveryContext/); -}); - -test('SendOtp: unsupported channel -> 400', async () => { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ channel: 9 })), ctx); - assert.equal(r.status, 400); - assert.match(r.jsonBody.reason, /channel/); +test('shared invalid requests return matching safe reasons before provider I/O', async () => { + const valid = { type: 'microsoft.mfa.otpDeliver.v1', channel: 1, mode: 1, encryptedDeliveryContext: 'unused' }; + for (const fixture of fixtures.badRequests) { + const result = await invoke(fixture.rawBody ?? { ...valid, ...fixture.overrides }); + assertFailure(result, 400, 'bad_request'); + assert.ok(result.jsonBody.requestId); + assert.deepEqual(result.jsonBody, { error: 'bad_request', reason: fixture.reason, + requestId: result.jsonBody.requestId }, fixture.name); + } + for (const changes of fixtures.incompleteContexts) { + const result = await invoke(await envelope({ mode: 2 }, { ...delivery, ...changes })); + assertFailure(result, 400, 'bad_request'); + assert.deepEqual(result.jsonBody, { error: 'bad_request', reason: 'incomplete delivery context', + correlationId: 'correlation-id', requestId: result.jsonBody.requestId }); + } + assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); }); -test('SendOtp: unsupported mode -> 400', async () => { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ mode: 5 })), ctx); - assert.equal(r.status, 400); - assert.match(r.jsonBody.reason, /mode/); +test('real JWE requires five segments and rejects a bad tag', async () => { + process.env.EPP_ENCRYPTION_KEY_ID = 'mismatch'; + const body = await envelope(); + const parts = body.encryptedDeliveryContext.split('.'); + parts[4] = (parts[4][0] === 'A' ? 'B' : 'A') + parts[4].slice(1); + for (const invalid of [{ ...body, encryptedDeliveryContext: parts.join('.') }, + { ...body, encryptedDeliveryContext: parts.slice(0, 4).join('.') }]) { + assertFailure(await invoke(invalid), 400, 'decryption_failed'); + assert.deepEqual(warnings, []); + } + assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); }); -test('SendOtp: undecryptable context -> 400 decryption_failed', async () => { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ encryptedDeliveryContext: 'eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIn0.bad.bad.bad.bad' })), ctx); - assert.equal(r.status, 400); - assert.equal(r.jsonBody.error, 'decryption_failed'); +test('shared JWE policy permits only RSA-OAEP-256 with A256GCM', async () => { + for (const { alg, enc, accepted } of fixtures.jwe) { + const result = await invoke(await envelope({ mode: 2 }, delivery, { alg, enc })); + if (accepted) { + assert.equal(result.status, 200); + assert.equal(result.jsonBody.nonce, delivery.nonce); + } else { + assertFailure(result, 400, 'decryption_failed'); + assert.deepEqual(result.jsonBody, { error: 'decryption_failed', correlationId: 'correlation-id', + requestId: result.jsonBody.requestId }); + } + } + assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); }); -test('SendOtp: incomplete context (no phoneNumber) -> 400', async () => { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({}, { nonce: 'n', message: 'm' })), ctx); - assert.equal(r.status, 400); - assert.match(r.jsonBody.reason, /incomplete/); +test('JWE authenticates the original protected-header bytes, not reserialized JSON', async () => { + const header = '{ "kid" : "test-key", "enc" : "A256GCM", "alg" : "RSA-OAEP-256" }'; + const encodedHeader = Buffer.from(header).toString('base64url'); + const key = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + cipher.setAAD(Buffer.from(encodedHeader, 'ascii')); + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(delivery), 'utf8'), cipher.final()]); + const wrappedKey = crypto.publicEncrypt({ key: publicKey, oaepHash: 'sha256', + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING }, key); + const segments = [encodedHeader, ...[wrappedKey, iv, ciphertext, cipher.getAuthTag()] + .map(value => value.toString('base64url'))]; + const body = await envelope({ mode: 2, encryptedDeliveryContext: segments.join('.') }); + const result = await invoke(body); + assert.equal(result.status, 200); + assert.equal(result.jsonBody.nonce, delivery.nonce); + segments[0] = Buffer.from(JSON.stringify(JSON.parse(header))).toString('base64url'); + assertFailure(await invoke({ ...body, encryptedDeliveryContext: segments.join('.') }), 400, 'decryption_failed'); + assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); }); -test('SendOtp: Live with ttlSeconds <= 0 still delivers, but warns', async () => { - const lines = []; - const warnCtx = { log: (m) => lines.push(String(m)), warn: (m) => lines.push(String(m)), error: () => {} }; - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ ttlSeconds: 0 })), warnCtx); - await whenDelivered(); - assert.equal(r.status, 200); - assert.ok(lines.some((l) => /has expired/.test(l)), 'expected an expiry warning'); +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]; + 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' })); + 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]); }); -test('SendOtp: valid Live envelope -> 200 accepted, nonce echoed, sent over https', async () => { - sent = undefined; - const r = await handlers.SendOtp(makeReq(await makeEnvelope()), ctx); - await whenDelivered(); - assert.equal(r.status, 200); - assert.equal(r.jsonBody.providerStatus, 'accepted'); - assert.equal(r.jsonBody.nonce, 'nonce-abc'); - assert.equal(r.jsonBody.correlationId, 'corr-1'); - assert.match(sent.url, /^https:\/\//); +test('SMS/voice preserve content and correlation without reflecting headers or logging PII', async () => { + const correlationId = 'PRIVATE-CORRELATION'; + const forgedHeaders = { authorization: 'Bearer FORGED-BEARER', + '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']]) { + const headers = channel === 1 ? {} : forgedHeaders; + const result = await invoke(await envelope({ channel, correlationId, provider: 'unknown' }), 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]; + const sent = JSON.parse(init.body); + assert.deepEqual([sent.text, sent.messageTypes, sent.correlationId], [delivery.message, [name], correlationId]); + 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/); + const output = JSON.stringify([result.jsonBody, logs, warnings]); + assert.doesNotMatch(output, /FORGED/); + for (const value of Object.values(forgedHeaders)) assert.equal(output.includes(value), false); + } + assert.equal(fetchMock.mock.callCount(), 2); }); -test('SendOtp: Evaluation mode -> 200 nonce echoed, nothing sent', async () => { - let calls = 0; - const original = global.fetch; - global.fetch = async (...a) => { calls++; return original(...a); }; - try { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ mode: 2 })), ctx); - await whenDelivered(); - assert.equal(r.status, 200); - assert.equal(r.jsonBody.nonce, 'nonce-abc'); - assert.equal(calls, 0); - } finally { - global.fetch = original; +test('handler awaits the provider body and returns 502/429 without a nonce or retries', async () => { + for (const status of [500, 429]) { + let release; + let bodyStarted; + const started = new Promise((resolve) => { bodyStarted = resolve; }); + const body = new Promise((resolve) => { release = resolve; }); + fetchMock.mock.mockImplementation(async () => ({ ok: false, status, + text: () => { bodyStarted(); return body; } })); + let settled = false; + const pending = invoke(await envelope()).then((value) => { settled = true; return value; }); + try { + await started; + assert.equal(settled, false); + assert.deepEqual(logs, []); + } finally { + release(JSON.stringify({ status: 'ENROUTE', description: 'PRIVATE-STATUS' })); + } + assertFailure(await pending, status === 500 ? 502 : 429); } + assert.equal(fetchMock.mock.callCount(), 2); }); -test('SendOtp: REQUIRE_AUTH enabled but misconfigured -> 401', async () => { - process.env.EPP_REQUIRE_AUTH = 'true'; - try { - const r = await handlers.SendOtp(makeReq(await makeEnvelope(), { authorization: 'Bearer abc' }), ctx); - assert.equal(r.status, 401); - } finally { - delete process.env.EPP_REQUIRE_AUTH; - } +test('the real abort timer covers response-body reading: 504, no retry and no nonce', async () => { + process.env.EPP_PROVIDER_TIMEOUT_MS = '1'; + fetchMock.mock.mockImplementation(async (_url, { signal }) => ({ + ok: true, status: 200, + text: () => new Promise((_resolve, reject) => { + const abort = () => reject(new Error('PRIVATE-TIMEOUT')); + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + }), + })); + assertFailure(await invoke(await envelope()), 504); + assert.equal(fetchMock.mock.callCount(), 1); + assert.equal(fetchMock.mock.calls[0].arguments[1].signal.aborted, true); }); diff --git a/python/.funcignore b/python/.funcignore new file mode 100644 index 0000000..9afe4b7 --- /dev/null +++ b/python/.funcignore @@ -0,0 +1,15 @@ +local.settings*.json +**/local.settings*.json +.env* +**/.env* +.keys/ +**/.keys/ +**/*.pem +**/*.pfx +**/*.key +.venv/ +venv/ +tests/ +**/__pycache__/ +.pytest_cache/ +.vscode/ \ No newline at end of file diff --git a/python/README.md b/python/README.md index 45ec00d..968ded0 100644 --- a/python/README.md +++ b/python/README.md @@ -1,45 +1,58 @@ # External Phone Provider Function — Python (v2 model) -A Python implementation of the External Phone Provider OTP-delivery Function, conforming to the shared -[contract](../docs/CONTRACT.md). Same design as the [`javascript/`](../javascript/) and -[`dotnet/`](../dotnet/) versions: one dispatch engine + drop-in provider adapters, env-provisioned -config, secrets in Key Vault. - -## Layout - -``` -python/ -├─ function_app.py # HTTP trigger: POST /api/SendOtp (v2 model) -├─ requirements.txt -├─ src/ -│ ├─ dispatch.py # envelope parse → JWE decrypt → provider dispatch -│ ├─ registry.py # adapter registry + EPP_PROVIDER_NAME resolution -│ ├─ providers/*.py # infobip, telesign, soprano, sinch (manifest + build/parse) -│ ├─ secrets.py # Key Vault via managed identity (cached) -│ ├─ outcome.py # status → outcome → HTTP status -│ ├─ models.py # DispatchRequest + outcome constants -│ └─ security.py # Entra JWT validation when EPP_REQUIRE_AUTH=true -└─ tests/ # pytest conformance tests -``` - -## Build, test, run - -```bash -cd python -python -m venv .venv && .venv\Scripts\activate # (macOS/Linux: source .venv/bin/activate) -pip install -r requirements.txt pytest -python -m pytest tests # run conformance tests -func start # run locally (copy ../docs/local.settings.sample.json) -``` - -## Deploy - -```bash -func azure functionapp publish # Linux Python Function App -``` - -The app's **managed identity** needs the **Key Vault Secrets User** role on the vault. Configuration -(env var names, Key Vault secret names, behaviors) is identical to the contract — see -[`../docs/CONTRACT.md`](../docs/CONTRACT.md). - -Target: Azure Functions Python **v2** programming model (Python 3.11), Functions v4. +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. + +## Setup and deployment + +1. Follow [customer onboarding](../docs/ONBOARDING.md). Set `EPP_PROVIDER_NAME` to the selected + adapter's registered manifest id (`` is only a placeholder). +2. Consult the selected adapter and its manifest in [src/providers/](src/providers/) for required + credentials and options. Store credentials in Key Vault under the declared secret names, grant + the Function's managed identity *Key Vault Secrets User*, and configure the matching endpoint/options. +3. Base private local settings on [../docs/local.settings.sample.json](../docs/local.settings.sample.json), + replacing placeholders and selecting `FUNCTIONS_WORKER_RUNTIME=python`. Put settings at the + app root beside [host.json](host.json). Configure decryption from the + [shared catalog](../docs/CONTRACT.md#4-configuration-app-settings--env) and caller trust through + [Easy Auth](../docs/ONBOARDING.md#2-provision-encryption-and-deployment-trust), the only authentication + gate before the anonymous Function. Enable `requireAuthentication=true`, + `unauthenticatedClientAction=Return401` and `requireHttps=true`; pin the trusted tenant issuer, + endpoint-app `allowedAudiences` and a nonempty `allowedApplications` list for the authorized SAS + caller. Do not exclude SendOtp. There is no backup application token validation; never expose the + endpoint to the public internet with Easy Auth disabled or bypassed. +4. Use a virtual environment, install [requirements.txt](requirements.txt) and pytest, then run the + offline [tests/](tests/) from this folder. Start the local Functions host from this app root. + Core Tools has no Easy Auth: bind only to loopback, with no tunnels or public forwarding. +5. Publish this folder to a compatible Linux Python Function App with dependencies or a supported + remote build. Inspect the package and apply [.funcignore](.funcignore); keep local settings and keys private. + Offline tests cover application behavior, not platform authentication; run the separate + [deployed security checks](../docs/ONBOARDING.md#4-package-deploy-and-verify). + +## Request behavior + +`POST /api/SendOtp` uses the same request and trust boundaries as the other runtimes. Incoming +`mode`, `channel`, `ttlSeconds` and `tenantId` are request data, not deployment authentication settings. +Easy Auth authenticates and authorizes the caller before the anonymous handler validates the envelope +and decrypts the JWE. Incoming `Authorization` is not parsed or echoed by the handler. JWE does not +authenticate SAS: anyone with the public key can encrypt a request, and a fixed nonce is not authentication. + +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. +Platform/key prerequisites and HTTP outcomes are defined in the +[contract](../docs/CONTRACT.md#evaluation-generic-shutter). + +## Source + +| Source | Purpose | +|---|---| +| [function_app.py](function_app.py) | HTTP handler and adapter registration | +| [src/config.py](src/config.py) | Shared deployment settings | +| [src/dispatch.py](src/dispatch.py) | Request model, JWE, provider registry and outcome mapping | +| [src/providers/](src/providers/) | Adapter manifests and API-specific implementations | +| [src/secrets.py](src/secrets.py) | Cached Key Vault access via managed identity | + +Add and register an adapter without adding provider-specific branches to the shared pipeline. +See [production limitations](../docs/CONTRACT.md#production-limitations) before production use. diff --git a/python/function_app.py b/python/function_app.py index 4bc21af..6286cd2 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -1,17 +1,13 @@ -"""POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the caller, parses -the cleartext routing envelope, decrypts the JWE delivery context, dispatches to the provider, and -echoes the nonce to prove decryption. Every line is tagged [EPP] so one filter pulls a whole delivery. -""" -import base64 +import hashlib import json import logging import os -import threading import time import uuid import azure.functions as func +from src.config import read_config from src.dispatch import ( MODE_EVALUATION, DispatchEngine, @@ -26,7 +22,6 @@ from src.providers.soprano import SopranoProvider from src.providers.telesign import TelesignProvider from src.secrets import SecretResolver -from src.security import validate_token TAG = "[EPP]" @@ -38,139 +33,75 @@ _key_provider = make_key_provider(os.environ) -def _json(status_code, body): - return func.HttpResponse(json.dumps(body), status_code=status_code, mimetype="application/json") - - -def _log(label, value): - logging.info("%s %-18s: %s", TAG, label, value) - - -def _read_caller_app_id(req): - """Easy Auth has already validated the token; this records which identity arrived.""" - encoded = req.headers.get("x-ms-client-principal") - if not encoded: - return None - try: - principal = json.loads(base64.b64decode(encoded).decode("utf-8")) - for claim in principal.get("claims") or []: - if claim.get("typ") in ("appid", "azp"): - return claim.get("val") - except Exception: - return None - return None - - +# Azure Easy Auth must enforce authentication; local handler calls are anonymous. @app.route(route="SendOtp", methods=["POST"], auth_level=func.AuthLevel.ANONYMOUS) def send_otp(req: func.HttpRequest) -> func.HttpResponse: - started = time.time() - request_id = uuid.uuid4().hex + started = time.monotonic() + request_id = str(uuid.uuid4()) client_request_id = req.headers.get("x-ms-client-request-id") or request_id header_correlation_id = req.headers.get("x-ms-correlation-id") - log_plaintext = (os.environ.get("EPP_LOG_PLAINTEXT") or "").lower() == "true" - expected_key_id = os.environ.get("EPP_ENCRYPTION_KEY_ID") - expected_client_id = os.environ.get("EPP_EXPECTED_CLIENT_ID") + correlation_id = header_correlation_id or request_id + http_status = 500 + evaluation = False - logging.info("%s ======== delivery received ========", TAG) - _log("invocation", request_id) + def respond(status, body): + nonlocal http_status + response = func.HttpResponse(json.dumps(body), status_code=status, mimetype="application/json") + http_status = status + return response - correlation_id = None try: - caller_app_id = _read_caller_app_id(req) - _log("caller appid", caller_app_id or "none (Easy Auth off, or called directly)") - - if caller_app_id and expected_client_id and caller_app_id != expected_client_id: - logging.error("%s caller %s is not %s. Easy Auth allowedApplications is not doing its job.", - TAG, caller_app_id, expected_client_id) - return _json(403, {"error": "unexpected_caller"}) - - auth_ok, reason, _caller_object_id = validate_token(req.headers.get("Authorization")) - if not auth_ok: - logging.error("%s token rejected: %s", TAG, reason) - return _json(401, {"error": "unauthorized", "reason": reason, "requestId": request_id}) - + config = read_config() try: payload = req.get_json() except ValueError: - logging.error("%s body is not JSON", TAG) - return _json(400, {"error": "bad_request", "reason": "invalid JSON body", "requestId": request_id}) + return respond(400, {"error": "bad_request", "reason": "invalid JSON body", "requestId": request_id}) envelope, error = parse_envelope(payload) if error: - logging.error("%s envelope rejected: %s", TAG, error) - return _json(400, {"error": "bad_request", "reason": error, "requestId": request_id}) - - _log("type", envelope["type"]) - _log("tenantId", envelope["tenant_id"]) - _log("correlationId", envelope["correlation_id"]) - _log("channel", envelope["channel"]) - _log("mode", envelope["mode"]) - _log("ttlSeconds", envelope["ttl_seconds"]) + return respond(400, {"error": "bad_request", "reason": error, "requestId": request_id}) correlation_id = envelope["correlation_id"] or header_correlation_id or request_id - - # Surfaced rather than swallowed: the passcode expires before it can be used. - ttl_seconds = envelope["ttl_seconds"] - if isinstance(ttl_seconds, (int, float)) and not isinstance(ttl_seconds, bool) and ttl_seconds <= 0: - logging.warning("%s ttlSeconds is %s; the passcode has expired.", TAG, ttl_seconds) + envelope["correlation_id"] = correlation_id + evaluation = envelope["mode"] == MODE_EVALUATION try: header, delivery = decrypt_delivery_context(envelope["encrypted_delivery_context"], _key_provider) - except Exception as err: - logging.error("%s decryption failed: %s", TAG, err) - return _json(400, {"error": "decryption_failed", "correlationId": correlation_id, "requestId": request_id}) - - kid = header.get("kid") - kid_matches = not expected_key_id or kid == expected_key_id - _log("kid", f"{kid}{'' if kid_matches else ' (DOES NOT match EPP_ENCRYPTION_KEY_ID)'}") - _log("alg / enc", f"{header.get('alg')} / {header.get('enc')}") - _log("decrypted", "OK") - _log("nonce", delivery.get("nonce")) - - if log_plaintext: - # DIAGNOSTICS ONLY — writes the phone number and passcode to the log. - _log("phoneNumber", delivery.get("phoneNumber")) - _log("extension", delivery.get("extension") or "(none)") - _log("locale", delivery.get("locale")) - _log("message", delivery.get("message")) - _log("riskContext", json.dumps(delivery["riskContext"]) if delivery.get("riskContext") else "(none)") - else: - logging.info("%s plaintext suppressed (EPP_LOG_PLAINTEXT=false)", TAG) - - if not delivery.get("nonce") or not delivery.get("phoneNumber") or not delivery.get("message"): - logging.error("%s delivery context is incomplete (nonce/phoneNumber/message)", TAG) - return _json(400, {"error": "bad_request", "reason": "incomplete delivery context", - "correlationId": correlation_id, "requestId": request_id}) - - evaluation = envelope["mode"] == MODE_EVALUATION - dispatch = context_to_dispatch(delivery, envelope, client_request_id) - - # Microsoft allows 3.2 s for the whole call, so the provider is called after the response. - def _deliver(): - try: - status, body = _engine.dispatch(dispatch, None, evaluation, request_id, logging) - logging.info( - "%s provider result : httpStatus=%s outcome=%s providerStatus=%s providerMessageId=%s correlationId=%s", - TAG, status, body.get("outcome") or "n/a", body.get("providerStatus") or "n/a", - body.get("providerMessageId") or "n/a", correlation_id) - except Exception as delivery_error: - logging.error("%s provider delivery failed: %s", TAG, delivery_error) - - # daemon so a stalled provider call cannot hold up worker shutdown. - threading.Thread(target=_deliver, name="epp-delivery", daemon=True).start() - - # Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery and - # Microsoft re-sends over its own telephony, so the user gets the code twice. - _log("responding", f"200, nonce echoed, {(time.time() - started) * 1000:.0f} ms") - logging.info("%s ======== done ========", TAG) - - return _json(200, { + except Exception: + return respond(400, {"error": "decryption_failed", "correlationId": correlation_id, "requestId": request_id}) + + if config["expected_key_id"] and header.get("kid") != config["expected_key_id"]: + logging.warning('encryption_key_id_mismatch') + + if not isinstance(delivery, dict) or not all( + isinstance(delivery.get(field), str) and delivery[field].strip() + for field in ("nonce", "phoneNumber", "message") + ): + return respond(400, {"error": "bad_request", "reason": "incomplete delivery context", + "correlationId": correlation_id, "requestId": request_id}) + + # Evaluation skips provider lookup, configuration, secrets and HTTP. + if not evaluation: + dispatch = context_to_dispatch(delivery, envelope, client_request_id) + status, _ = _engine.dispatch(dispatch, request_id) + if status != 200: + return respond(status, {"error": "provider_delivery_failed", + "correlationId": correlation_id, "requestId": request_id}) + + # Live delivery must finish before nonce acceptance. + return respond(200, { "nonce": delivery["nonce"], "correlationId": correlation_id, "providerStatus": "accepted", }) - except Exception as error: - # Verbose on purpose: this endpoint exists to diagnose onboarding. - logging.error("%s FAILED after %.0f ms: %s", TAG, (time.time() - started) * 1000, error) - logging.info("%s ======== failed ========", TAG) - return _json(500, {"error": "delivery_failed", "detail": str(error), "correlationId": correlation_id}) + except Exception: + return respond(500, {"error": "provider_delivery_failed", "correlationId": correlation_id, "requestId": request_id}) + finally: + # Hash even generated correlations; wire IDs stay raw. + logging.info("%s result %s", TAG, json.dumps({ + "requestId": request_id, + "correlationId": hashlib.sha256(str(correlation_id).encode("utf-8")).hexdigest()[:16], + "httpStatus": http_status, + "elapsedMs": int((time.monotonic() - started) * 1000), + "evaluation": evaluation, + })) diff --git a/python/requirements.txt b/python/requirements.txt index 630ae21..41bbff8 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -5,5 +5,4 @@ azure-functions>=1.21,<1.26 azure-identity>=1.16,<2 azure-keyvault-secrets>=4.8,<5 requests>=2.31,<3 -PyJWT[crypto]>=2.8,<3 jwcrypto>=1.5,<2 diff --git a/python/src/config.py b/python/src/config.py new file mode 100644 index 0000000..442c307 --- /dev/null +++ b/python/src/config.py @@ -0,0 +1,13 @@ +import os + + +def read_config(env=None): + env = os.environ if env is None else env + return { + "decryption_key_pem": env.get("EPP_DECRYPTION_KEY_PEM") or "", + "expected_key_id": env.get("EPP_ENCRYPTION_KEY_ID"), + "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"), + "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 aca13cb..0eb50d1 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -1,19 +1,19 @@ -"""Delivery pipeline: parse the cleartext SAS envelope, decrypt the JWE that carries the PII, then -dispatch to the configured provider. Fail-closed — only a Continue outcome is "accepted".""" import base64 import json import os -import re from dataclasses import dataclass +from urllib.parse import urlsplit import requests from jwcrypto import jwe as jwe_module from jwcrypto import jwk +from urllib3.exceptions import ReadTimeoutError + +from .config import read_config DEFAULT_TIMEOUT_MS = 1500 DEFAULT_CHANNELS = ["sms", "voice"] -# Outcomes (mirrors the other languages). CONTINUE = "Continue" FAIL = "Fail" BLOCK = "Block" @@ -31,13 +31,13 @@ class DispatchRequest: def resolve_outcome(manifest, parsed): - """A recognized status wins; an unknown status is fail-closed; only a status-less response - trusts the HTTP result.""" mapping = manifest["response_mapping"] key = parsed.get("provider_status_name") or parsed.get("provider_status_code") if key: - return mapping.get(key) or mapping.get("default", FAIL) - return CONTINUE if parsed.get("success") else mapping.get("default", FAIL) + outcome = mapping.get(key) or mapping.get("default", FAIL) + else: + outcome = CONTINUE if parsed.get("success") else mapping.get("default", FAIL) + return FAIL if outcome == CONTINUE and not parsed.get("success") else outcome def to_http_status(outcome, provider_http_status): @@ -58,8 +58,6 @@ def to_http_status(outcome, provider_http_status): class ProviderRegistry: - """One provider is active per deployment; request_provider is a test override.""" - def __init__(self, adapters): self._by_id = {adapter.manifest["id"].lower(): adapter for adapter in adapters} @@ -68,11 +66,7 @@ def get(self, provider_id): return None return self._by_id.get(provider_id.lower()) - def resolve(self, request_provider): - return self.get(request_provider or os.environ.get("EPP_PROVIDER_NAME")) - -# Channel: 1=Sms, 2=Voice. DeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). CHANNEL_BY_CODE = {1: "sms", 2: "voice"} CHANNEL_BY_NAME = {"sms": 1, "voice": 2} MODE_LIVE = 1 @@ -83,45 +77,50 @@ def resolve(self, request_provider): def _normalize_channel(channel): - if isinstance(channel, bool): - return None - if channel in CHANNEL_BY_CODE: - return channel + if type(channel) is int: + return channel if channel in CHANNEL_BY_CODE else None if isinstance(channel, str): return CHANNEL_BY_NAME.get(channel.lower()) return None def _normalize_mode(mode): - if isinstance(mode, bool): - return None - if mode in (MODE_LIVE, MODE_EVALUATION): - return mode + if type(mode) is int: + return mode if mode in (MODE_LIVE, MODE_EVALUATION) else None if isinstance(mode, str): return MODE_BY_NAME.get(mode.lower()) return None def parse_envelope(payload): - """Returns (envelope, None) or (None, error).""" if not isinstance(payload, dict): return None, "invalid envelope" + if payload.get("type") != "microsoft.mfa.otpDeliver.v1": + return None, "unsupported envelope type" encrypted = payload.get("encryptedDeliveryContext") - if not isinstance(encrypted, str) or not encrypted: + if not isinstance(encrypted, str) or not encrypted.strip(): return None, "encryptedDeliveryContext is required" channel = _normalize_channel(payload.get("channel")) if channel is None: - return None, f"unsupported channel '{payload.get('channel')}'" + return None, "unsupported channel" mode = _normalize_mode(payload.get("mode")) if mode is None: - return None, f"unsupported mode '{payload.get('mode')}'" + return None, "unsupported mode" + ttl_seconds = payload.get("ttlSeconds") + if "ttlSeconds" in payload: + if type(ttl_seconds) is not int: + return None, "invalid ttlSeconds" + if ttl_seconds <= 0: + return None, "ttlSeconds expired" + if ttl_seconds > 2147483647: + return None, "invalid ttlSeconds" return { "type": payload.get("type"), "tenant_id": payload.get("tenantId"), "correlation_id": payload.get("correlationId"), "channel": channel, "mode": mode, - "ttl_seconds": payload.get("ttlSeconds"), + "ttl_seconds": ttl_seconds, "encrypted_delivery_context": encrypted, }, None @@ -134,13 +133,12 @@ def read_protected_header(compact_jwe): def make_key_provider(env): def key_provider(_kid): - return env.get("EPP_DECRYPTION_KEY_PEM") or "" + return read_config(env)["decryption_key_pem"] return key_provider def _assert_well_formed_jwe(compact_jwe): - # Reject oversized or malformed input before decoding or allocating buffers. if not isinstance(compact_jwe, str) or not compact_jwe: raise ValueError("malformed JWE") if len(compact_jwe) > MAX_JWE_LENGTH: @@ -150,13 +148,12 @@ def _assert_well_formed_jwe(compact_jwe): raise ValueError("malformed JWE: expected five non-empty segments") -# Imported once: a per-delivery RSA import would sit inside the response budget. +# Cache only the configured key to avoid repeated RSA imports. _key_cache = {} def _normalize_pem(value): - """The setup script stores the key as base64 over the PEM so its newlines survive being carried as - an app setting, so accept either form.""" + # Base64 preserves PEM newlines in app settings. text = value if isinstance(value, str) else value.decode("utf-8") if "-----BEGIN" in text: return text @@ -175,27 +172,18 @@ def _load_private_key(pem): def decrypt_delivery_context(compact_jwe, key_provider): - """key_provider(kid) -> PEM string. Returns (header, delivery context dict).""" _assert_well_formed_jwe(compact_jwe) header = read_protected_header(compact_jwe) key = _load_private_key(key_provider(header.get("kid"))) - # Pin alg/enc so a tampered header can't downgrade the crypto. token = jwe_module.JWE(algs=["RSA-OAEP-256", "A256GCM"]) token.deserialize(compact_jwe, key=key) return header, json.loads(token.payload.decode("utf-8")) -def _space_passcode_for_voice(message): - """Left alone, TTS reads 641895 as "six hundred forty-one thousand...", which no user can type.""" - return re.sub(r"\b\d{4,8}\b", lambda m: " ".join(m.group(0)), message or "", count=1) - - def context_to_dispatch(context, envelope, message_id): - """The message is pre-rendered and already contains the passcode, so there is no separate code.""" return DispatchRequest( destination=context.get("phoneNumber"), - message=(_space_passcode_for_voice(context.get("message")) - if CHANNEL_BY_CODE[envelope["channel"]] == "voice" else context.get("message")), + message=context.get("message"), channel=CHANNEL_BY_CODE[envelope["channel"]], message_id=message_id, correlation_id=envelope["correlation_id"], @@ -203,111 +191,159 @@ def context_to_dispatch(context, envelope, message_id): ) +def _valid_provider_url(value): + if not isinstance(value, str) or not value or "#" in value: + return False + # urlsplit strips controls; reject them before parsing. + if any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in value): + return False + try: + parsed = urlsplit(value) + port = parsed.port # Access validates the port's syntax and range. + return ( + parsed.scheme == "https" + and bool(parsed.hostname) + and port != 0 + and parsed.username is None + and parsed.password is None + and not parsed.fragment + and not parsed.netloc.endswith(":") + ) + except ValueError: + return False + + +def _provider_timeout_ms(value): + digits = value.strip() if isinstance(value, str) else "" + if not digits or not digits.isascii() or not digits.isdecimal(): + return DEFAULT_TIMEOUT_MS + digits = digits.lstrip("0") + if not digits: + return DEFAULT_TIMEOUT_MS + # Clamp before int() to avoid its digit limit. + if len(digits) > 4 or (len(digits) == 4 and digits > "2500"): + return 2500 + return int(digits) + + +def _has_read_timeout(error): + # requests wraps urllib3 body-read timeouts in ConnectionError. + pending = [error] + seen = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + if isinstance(current, ReadTimeoutError): + return True + pending.extend( + nested for nested in (current.__cause__, current.__context__, *current.args) + if isinstance(nested, Exception) + ) + return False + + class DispatchEngine: def __init__(self, registry, secrets, env=None): self.registry = registry self.secrets = secrets self.env = env if env is not None else os.environ - def dispatch(self, dispatch, request_provider, shutter, request_id, log): - adapter = self.registry.resolve(request_provider) + def dispatch(self, dispatch, request_id): + config = read_config(self.env) + adapter = self.registry.get(config["provider_name"]) if adapter is None: - log.warning("[DISPATCH_ERROR] requestId=%s unknown provider=%s", request_id, request_provider or "n/a") return 400, {"status": "error", "reason": "unknown provider", "requestId": request_id} manifest = adapter.manifest provider_id = manifest["id"] - channel = (dispatch.channel or "sms").lower() + channel = dispatch.channel if dispatch.channel is not None else "sms" + if not isinstance(channel, str): + return 400, {"status": "error", "provider": provider_id, "reason": "unsupported channel", "requestId": request_id} + channel = channel.lower() if channel not in DEFAULT_CHANNELS: - return 400, {"status": "error", "provider": provider_id, "reason": f"channel '{channel}' not supported", "requestId": request_id} - - # Credential (fail closed 502 if missing) — this is our credential, not the caller's token. - credential = None - try: - credential = self._resolve_credential(manifest["auth"]) - except Exception as error: - log.error("[DISPATCH_ERROR] requestId=%s provider=%s credential error=%s", request_id, provider_id, error) + return 400, {"status": "error", "provider": provider_id, "reason": "unsupported channel", "requestId": request_id} auth = manifest["auth"] - identity_required = ( - credential is not None - and credential["mode"] == "apiKey" - and bool(auth.get("identity_key_vault_secret_name")) - ) - credential_unavailable = ( - credential is None - or (credential["mode"] == "oauth2" and not credential.get("token")) - or (credential["mode"] == "apiKey" and not credential.get("secret")) - or (identity_required and not credential.get("identity")) - ) - if credential_unavailable: + 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) - endpoint = self._resolve_endpoint(manifest) + endpoint = config["provider_endpoint"] if not endpoint: return 502, self._fail_body(provider_id, channel, "provider endpoint not configured", dispatch, request_id) - - provider_request = adapter.build_request(channel, endpoint, dispatch, credential, self.env) - log.info("[DISPATCH] requestId=%s provider=%s channel=%s shutter=%s", request_id, provider_id, channel, bool(shutter)) - - if shutter: - return 200, {"status": "accepted", "shutterProcessed": True, "provider": provider_id, "channel": channel, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} + if not _valid_provider_url(endpoint): + return 502, self._fail_body(provider_id, channel, "invalid provider endpoint", dispatch, request_id) try: - timeout_ms = int(self.env.get("EPP_PROVIDER_TIMEOUT_MS") or DEFAULT_TIMEOUT_MS) - except (TypeError, ValueError): - timeout_ms = DEFAULT_TIMEOUT_MS + provider_request = adapter.build_request(channel, endpoint, dispatch, credential, config["env"]) + except Exception: + return 502, self._fail_body(provider_id, channel, "provider request failed", 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( provider_request["method"], provider_request["url"], headers=provider_request["headers"], data=provider_request["body"], + # Connect/read inactivity, not a total delivery deadline. timeout=timeout_ms / 1000, + allow_redirects=False, # Never forward credentials to a redirect target. + stream=True, # Own the response for cleanup if body reading fails. ) - except requests.exceptions.Timeout: - log.warning("[DISPATCH_TIMEOUT] requestId=%s provider=%s", request_id, provider_id) - return 504, self._fail_body(provider_id, channel, f"endpoint timeout after {timeout_ms}ms", dispatch, request_id) - except requests.exceptions.RequestException as error: - log.error("[DISPATCH_ERROR] requestId=%s provider=%s reason=%s", request_id, provider_id, error) - return 502, self._fail_body(provider_id, channel, str(error), dispatch, request_id) - try: - body_json = response.json() - except ValueError: - 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.get("provider_http_status") or response.status_code) - - log.info("[DISPATCH_RESULT] requestId=%s provider=%s channel=%s outcome=%s httpStatus=%s", request_id, provider_id, channel, outcome, http_status) - - return http_status, { - "status": "accepted" if outcome == CONTINUE else "failed", - "outcome": outcome, - "provider": provider_id, - "channel": channel, - "messageId": dispatch.message_id, - "correlationId": dispatch.correlation_id, - "providerMessageId": parsed.get("provider_message_id"), - "providerStatus": parsed.get("provider_status_name") or parsed.get("provider_status_code"), - "providerStatusDescription": parsed.get("provider_status_description"), - "requestId": request_id, - } + try: + body_json = response.json() + except ValueError: + 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.get("provider_http_status") or response.status_code) + + return http_status, { + "status": "accepted" if outcome == CONTINUE else "failed", + "outcome": outcome, + "provider": provider_id, + "channel": channel, + "messageId": dispatch.message_id, + "correlationId": dispatch.correlation_id, + "requestId": request_id, + } + except requests.exceptions.RequestException as error: + if response is None: + response = getattr(error, "response", None) + if isinstance(error, requests.exceptions.Timeout) or ( + isinstance(error, requests.exceptions.ConnectionError) and _has_read_timeout(error) + ): + return 504, self._fail_body(provider_id, channel, "provider timeout", dispatch, request_id) + return 502, self._fail_body(provider_id, channel, "provider request failed", dispatch, request_id) + except Exception: + return 502, self._fail_body(provider_id, channel, "provider response failed", dispatch, request_id) + finally: + close = getattr(response, "close", None) + if callable(close): + try: + close() + except Exception: + pass def _resolve_credential(self, auth): - if auth.get("mode") == "oauth2": - return {"mode": "oauth2", "token": None} # not wired -> fails closed 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_endpoint(self, manifest): - # One provider is active per deployment, so the endpoint is a single EPP_PROVIDER_ENDPOINT. - return self.env.get("EPP_PROVIDER_ENDPOINT") - 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/providers/infobip.py b/python/src/providers/infobip.py index 097eb0a..aad5d63 100644 --- a/python/src/providers/infobip.py +++ b/python/src/providers/infobip.py @@ -1,4 +1,3 @@ -"""Infobip: SMS via /sms/3/messages, voice via /tts/3/advanced. Auth: App API key.""" import json @@ -19,7 +18,7 @@ class InfobipProvider: def build_request(self, channel, endpoint, dispatch, credential, env): sender_id = env.get("EPP_PROVIDER_ACCOUNT_NAME") or "Verify" - authorization = f"Bearer {credential['token']}" if credential["mode"] == "oauth2" else f"App {credential['secret']}" + authorization = f"App {credential['secret']}" headers = {"Authorization": authorization, "Content-Type": "application/json", "Accept": "application/json"} message_id = dispatch.correlation_id or dispatch.message_id diff --git a/python/src/providers/sinch.py b/python/src/providers/sinch.py index 0638908..8ce2c7c 100644 --- a/python/src/providers/sinch.py +++ b/python/src/providers/sinch.py @@ -1,5 +1,3 @@ -"""Sinch: SMS via XMS Batches (POST /xms/v1/{plan}/batches, Bearer). -Voice via the Calling TTS callout API.""" import json @@ -14,7 +12,7 @@ class SinchProvider: } def build_request(self, channel, endpoint, dispatch, credential, env): - bearer = credential["token"] if credential["mode"] == "oauth2" else credential["secret"] + bearer = credential["secret"] headers = {"Authorization": f"Bearer {bearer}", "Content-Type": "application/json", "Accept": "application/json"} reference = dispatch.correlation_id or dispatch.message_id diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index ac44b8f..d6ae863 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -1,5 +1,3 @@ -"""Soprano Connect (MEMS): POST {base}/messages/{sms|voice}. -Auth: X-MEMS-API-ID + X-MEMS-API-Key.""" import json @@ -14,60 +12,40 @@ class SopranoProvider: "response_mapping": { "ENROUTE": "Continue", "ACCEPTED": "Continue", "SUBMITTED": "Continue", "SENT": "Continue", "DELIVERED": "Continue", "QUEUED": "Continue", - "FAILED": "Fail", "REJECTED": "Fail", "BLOCKED": "Block", "default": "Fail", + "FAILED": "Fail", "REJECTED": "Fail", "FILTERED": "Fail", "BLOCKED": "Block", "default": "Fail", }, } def build_request(self, channel, endpoint, dispatch, credential, env): message_type = "voice" if channel == "voice" else "sms" - headers = {"Content-Type": "application/json", "Accept": "application/json"} - if credential["mode"] == "oauth2": - headers["Authorization"] = f"Bearer {credential['token']}" - else: - headers["X-MEMS-API-ID"] = credential.get("identity") or "" - headers["X-MEMS-API-Key"] = credential.get("secret") or "" - - client_reference = dispatch.correlation_id or dispatch.message_id - body = {"messageType": message_type, "destination": dispatch.destination, "clientReference": client_reference} - - # Sender: a provisioned source endpoint is what Soprano accepts; free-text source is a fallback. - # Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A - # non-numeric account name is sent as a free-text source instead. - account = env.get("EPP_PROVIDER_ACCOUNT_NAME") - if account and str(account).isdigit(): - source_type = int(env.get("SOPRANO_SOURCE_TYPE") or 1) - body["endpoints"] = [{"type": source_type, "id": int(account)}] - elif account: - body["source"] = account - - if message_type == "voice": - locale = dispatch.locale or "" - voice_language = env.get("SOPRANO_VOICE_LANGUAGE") or (locale if "-" in locale else "en-US") - body["voice"] = {"text2voice": { - "beforePasswordText": dispatch.message or "", - "password": "", - "afterPasswordText": "", - "language": voice_language, - "gender": int(env.get("SOPRANO_VOICE_GENDER") or 1), - "loop": 1, - }} - else: - body["text"] = dispatch.message - - return {"url": f"{endpoint}/messages/{message_type}", "method": "POST", "headers": headers, "body": json.dumps(body)} + headers = { + "X-MEMS-API-ID": credential.get("identity") or "", + "X-MEMS-API-Key": credential.get("secret") or "", + "Content-Type": "application/json", + "Accept": "application/json", + } + body = { + "text": dispatch.message, + "destination": str(dispatch.destination).lstrip("+"), + "messageTypes": [message_type], + "correlationId": dispatch.correlation_id or dispatch.message_id, + "shutterMode": False, + } + return {"url": f"{endpoint.rstrip('/')}/messages/omnimsg", "method": "POST", "headers": headers, "body": json.dumps(body)} def parse_response(self, http_status, ok, json_body): payload = json_body[0] if isinstance(json_body, list) and json_body else json_body payload = payload if isinstance(payload, dict) else {} identifier = payload.get("id") identifier = str(identifier) if identifier is not None else payload.get("messageId") - status = payload.get("status") or payload.get("state") - status = status.upper() if status else ("SUBMITTED" if ok else None) + value = payload.get("status") + if value is None: + value = payload.get("state") + status = value.upper() if isinstance(value, str) and value else "UNKNOWN" return { "success": ok, "provider_http_status": http_status, "provider_message_id": identifier, "provider_status_name": status, "provider_status_code": None, - "provider_status_description": payload.get("errorDescription") or payload.get("statusText") or payload.get("description"), } diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py index b03276c..7d1480c 100644 --- a/python/src/providers/telesign.py +++ b/python/src/providers/telesign.py @@ -1,5 +1,3 @@ -"""Telesign: SMS via /v1/messaging, voice via /v1/voice (form-urlencoded). -Auth: HTTP Basic (customer_id:api_key).""" import base64 import urllib.parse @@ -20,11 +18,8 @@ class TelesignProvider: } def build_request(self, channel, endpoint, dispatch, credential, env): - if credential["mode"] == "oauth2": - authorization = f"Bearer {credential['token']}" - else: - raw = f"{credential['identity']}:{credential['secret']}".encode() - authorization = "Basic " + base64.b64encode(raw).decode() + 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": diff --git a/python/src/secrets.py b/python/src/secrets.py index 9bf50f5..036cede 100644 --- a/python/src/secrets.py +++ b/python/src/secrets.py @@ -1,5 +1,3 @@ -"""Resolves Key Vault secret names to values via the Function's managed identity -(user-assigned when AZURE_CLIENT_ID is set, else system-assigned), cached briefly.""" import os import time diff --git a/python/src/security.py b/python/src/security.py deleted file mode 100644 index 0e69006..0000000 --- a/python/src/security.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Validates the Entra JWT when EPP_REQUIRE_AUTH=true (aud/issuer/JWKS, RS256). -No-op pass-through otherwise — Easy Auth is the primary gate; this is the backstop.""" -import os - -import jwt -from jwt import PyJWKClient - -_jwks_clients = {} - - -def _jwks_client(tenant_id): - client = _jwks_clients.get(tenant_id) - if client is None: - client = PyJWKClient(f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys") - _jwks_clients[tenant_id] = client - return client - - -def validate_token(authorization_header): - """Returns (ok, reason, caller_object_id).""" - if (os.environ.get("EPP_REQUIRE_AUTH") or "").lower() != "true": - return True, None, None - - audience = os.environ.get("EPP_EXPECTED_AUDIENCE") - tenant_id = os.environ.get("EPP_TENANT_ID") - if not audience or not tenant_id: - return False, "auth misconfigured", None - - if not authorization_header or not authorization_header.lower().startswith("bearer "): - return False, "missing bearer token", None - - token = authorization_header[len("bearer "):].strip() - try: - signing_key = _jwks_client(tenant_id).get_signing_key_from_jwt(token) - claims = jwt.decode( - token, - signing_key.key, - algorithms=["RS256"], - audience=audience, - options={"verify_iss": False}, - ) - allowed_issuers = ( - (os.environ.get("EPP_EXPECTED_ISSUER"),) - if os.environ.get("EPP_EXPECTED_ISSUER") - else ( - f"https://login.microsoftonline.com/{tenant_id}/v2.0", - f"https://sts.windows.net/{tenant_id}/", - ) - ) - if claims.get("iss") not in allowed_issuers: - return False, "token validation failed", None - - # azp is the v2 caller claim, appid the v1 one. - expected_client_id = os.environ.get("EPP_EXPECTED_CLIENT_ID") - if expected_client_id and (claims.get("azp") or claims.get("appid")) != expected_client_id: - return False, "unexpected caller", None - - return True, None, claims.get("oid") - except Exception: - return False, "token validation failed", None diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index db1cb31..3357a8c 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -1,65 +1,91 @@ -"""Conformance tests for the pure contract logic (see /docs/CONTRACT.md §6).""" -from src.dispatch import ( - BLOCK, - CONTINUE, - FAIL, - STEP_UP, - DispatchRequest, - ProviderRegistry, - resolve_outcome, - to_http_status, -) +import base64 +import json +from urllib.parse import parse_qs + +import pytest + +from src.dispatch import DispatchRequest, ProviderRegistry, parse_envelope from src.providers.infobip import InfobipProvider -from src.providers.telesign import TelesignProvider -from src.providers.soprano import SopranoProvider 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é. " -def _dispatch(channel="sms", message=None): - return DispatchRequest( - destination="+15551234567", message=message, channel=channel, - message_id="m", correlation_id="c", locale=None, - ) + +def _dispatch(channel="sms"): + return DispatchRequest("+15551234567", MESSAGE, channel, "message-id", "correlation-id", "en-US") -def test_outcome_and_http_status(): - manifest = InfobipProvider.manifest - assert resolve_outcome(manifest, {"success": True, "provider_status_name": "DELIVERED"}) == CONTINUE - # Unknown status fails closed even on HTTP 200. - assert resolve_outcome(manifest, {"success": True, "provider_status_name": "WATWAT"}) == FAIL - assert to_http_status(CONTINUE, 200) == 200 - assert to_http_status(BLOCK, 200) == 403 - assert to_http_status(STEP_UP, 200) == 409 - assert to_http_status(FAIL, 429) == 429 - assert to_http_status(FAIL, 403) == 401 - assert to_http_status(FAIL, 422) == 400 - assert to_http_status(FAIL, 500) == 502 +@pytest.mark.parametrize("channel", ["sms", "voice"]) +def test_soprano_exact_sms_and_voice_contract(channel): + request = ProviderRegistry([SopranoProvider()]).get("SOPRANO").build_request( + channel, "https://qa4.example/cgpapi///", _dispatch(channel), + {"mode": "apiKey", "identity": "test-id", "secret": "test-key"}, + {}, + ) + assert request["url"] == "https://qa4.example/cgpapi/messages/omnimsg" and request["method"] == "POST" + assert request["headers"] == { + "X-MEMS-API-ID": "test-id", "X-MEMS-API-Key": "test-key", + "Content-Type": "application/json", "Accept": "application/json", + } + assert json.loads(request["body"]) == { + "text": MESSAGE, "destination": "15551234567", "messageTypes": [channel], + "correlationId": "correlation-id", "shutterMode": False, + } -def test_infobip_builds_https_sms_request(): - env = {"INFOBIP_SENDER_ID": "EPP"} +def test_infobip_sms_contract(): request = InfobipProvider().build_request( - "sms", "https://api.infobip.com", - _dispatch(message="Use verification code 918273 for Microsoft authentication."), - {"mode": "apiKey", "secret": "ib"}, env, + "sms", "https://infobip.example", _dispatch(), + {"mode": "apiKey", "secret": "ib"}, {"EPP_PROVIDER_ACCOUNT_NAME": "EPP"}, ) - assert request["url"].startswith("https://") - assert request["url"].endswith("/sms/3/messages") - assert request["headers"]["Authorization"].startswith("App ") - assert "918273" in request["body"] + assert request["method"] == "POST" and request["url"] == "https://infobip.example/sms/3/messages" + assert request["headers"]["Authorization"] == "App ib" + assert json.loads(request["body"])["messages"] == [{ + "sender": "EPP", "destinations": [{"to": "+15551234567", "messageId": "correlation-id"}], + "content": {"text": MESSAGE}, + }] -def test_telesign_basic_auth_and_voice_mapping(): +def test_telesign_sms_contract(): request = TelesignProvider().build_request( - "sms", "https://rest-api.telesign.com", _dispatch(message="code 918273"), - {"mode": "apiKey", "secret": "key", "identity": "cust"}, {}, + "sms", "https://telesign.example", _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"] + + +def test_sinch_sms_static_token_contract(): + request = SinchProvider().build_request( + "sms", "https://sinch.example", _dispatch(), + {"mode": "apiKey", "secret": "static-api-token"}, + {"SINCH_SERVICE_PLAN_ID": "plan", "EPP_PROVIDER_ACCOUNT_NAME": "EPP"}, ) - assert request["headers"]["Authorization"].startswith("Basic ") - assert request["url"].endswith("/v1/messaging") - assert resolve_outcome(TelesignProvider.manifest, {"success": True, "provider_status_code": "100"}) == CONTINUE + assert request["method"] == "POST" and request["url"] == "https://sinch.example/xms/v1/plan/batches" + assert request["headers"]["Authorization"] == "Bearer static-api-token" + assert json.loads(request["body"]) == { + "from": "EPP", "to": ["+15551234567"], "body": MESSAGE, "client_reference": "correlation-id", + } -def test_registry_resolves_by_id(): - registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) - assert registry.get("TELESIGN").manifest["id"] == "telesign" - assert registry.get("nope") is None +def test_envelope_routing_and_ttl_validation(): + payload = {"type": "microsoft.mfa.otpDeliver.v1", "channel": 1, "mode": 1, "encryptedDeliveryContext": "jwe"} + for channel, mode, expected in ((1, 1, (1, 1)), ("VOICE", "Evaluation", (2, 2))): + envelope, error = parse_envelope({**payload, "channel": channel, "mode": mode}) + assert error is None and (envelope["channel"], envelope["mode"]) == expected + for changes in ({"channel": True}, {"mode": False}, {"channel": "1"}, {"mode": None}): + envelope, error = parse_envelope({**payload, **changes}) + assert envelope is None and error + for ttl in (None, True, "60", 0, 2147483648): + envelope, error = parse_envelope({**payload, "ttlSeconds": ttl}) + assert envelope is None and "ttlSeconds" in error + for ttl in (1, 2147483647): + envelope, error = parse_envelope({**payload, "ttlSeconds": ttl}) + assert error is None and envelope["ttl_seconds"] == ttl + assert parse_envelope(payload)[0]["ttl_seconds"] is None diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index f32a1ec..545258e 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -1,129 +1,85 @@ -"""Engine-level conformance tests (CONTRACT.md §6) with mocked HTTP + Key Vault.""" -import json +from unittest.mock import Mock import pytest +from urllib3.exceptions import ReadTimeoutError import src.dispatch as dispatch_module 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 -class FakeSecrets: - def __init__(self, values): - self._values = values - - def resolve(self, name): - return self._values.get(name, "") - - -class FakeResponse: - def __init__(self, status_code, body): - self.status_code = status_code - self._body = body - - def json(self): - return self._body - - -class CapturingLog: - def __init__(self): - self.lines = [] - - def _record(self, fmt, *args): - self.lines.append(fmt % args if args else fmt) - - info = _record - warning = _record - error = _record - - -_DEFAULT_SECRETS = { - "infobip-api-key": "ib", - "telesign-api-key": "ts", "telesign-customer-id": "cust", - "soprano-api-key": "sp", "soprano-api-id": "spid", -} -_DEFAULT_ENV = { - "EPP_PROVIDER_ENDPOINT": "https://api.infobip.com", -} - - -def make_engine(secret_values=None, env=None): - registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) - secrets = FakeSecrets(_DEFAULT_SECRETS if secret_values is None else secret_values) - return DispatchEngine(registry, secrets, _DEFAULT_ENV if env is None else env) - - -def dispatch_request(**overrides): - base = dict( - destination="+15551234567", message="Your code is 918273", channel="sms", - message_id="m", correlation_id="c", locale=None, +def _request(channel="sms"): + return DispatchRequest("+15551234567", "Your code is 918273", channel, "message", "correlation", "en-US") + + +@pytest.fixture +def engine(monkeypatch): + registry = ProviderRegistry([SopranoProvider(), SinchProvider()]) + monkeypatch.setattr(dispatch_module.requests, "request", Mock()) + return DispatchEngine(registry, Mock(resolve=Mock(return_value="test-key")), + {"EPP_PROVIDER_NAME": " SOPRANO ", "EPP_PROVIDER_ENDPOINT": "https://qa4.example/cgpapi/"}) + + +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" + status, body = engine.dispatch(_request(), "r") + assert status == 502 and body["reason"] == "provider credential unavailable" + dispatch_module.requests.request.assert_not_called() + + +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 + status, body = engine.dispatch(_request(), "r") + assert status == 502 and body["reason"] == "invalid provider endpoint" + engine.env["EPP_PROVIDER_ENDPOINT"] = "https://api.example" + engine.env["EPP_PROVIDER_NAME"] = "sinch" + for url in ("http://voice.example", "https://voice.example:0"): + engine.env["SINCH_VOICE_ENDPOINT"] = url + status, body = engine.dispatch(_request("voice"), "r") + assert status == 502 and body["reason"] == "invalid provider request URL" + dispatch_module.requests.request.assert_not_called() + + +def test_provider_outcomes_fail_closed(engine, monkeypatch): + monkeypatch.setenv("EPP_PROVIDER_NAME", "sinch") # The injected provider setting must win. + assert engine.registry.get(None) is None + cases = ( + (202, {"state": "accepted"}, 200, "Continue"), + (500, {"status": "ACCEPTED"}, 502, "Fail"), + (200, {"status": "FAILED"}, 502, "Fail"), + (200, {"status": "FILTERED"}, 502, "Fail"), + (200, {}, 502, "Fail"), + (200, {"status": False, "state": "ACCEPTED"}, 502, "Fail"), + (200, {"status": "BLOCKED"}, 403, "Block"), ) - base.update(overrides) - return DispatchRequest(**base) - - -def _mock_send(monkeypatch, response=None, raise_error=None, capture=None): - def fake_request(method, url, headers=None, data=None, timeout=None): - if capture is not None: - capture["url"] = url - capture["data"] = data - if raise_error is not None: - raise raise_error - return response - monkeypatch.setattr(dispatch_module.requests, "request", fake_request) - - -def test_unknown_provider_400(): - status, body = make_engine().dispatch(dispatch_request(), "nope", False, "r", CapturingLog()) - assert status == 400 and body["reason"] == "unknown provider" - - -def test_missing_credential_502(): - status, body = make_engine(secret_values={}).dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert status == 502 and body["reason"] == "provider credential unavailable" - - -def test_missing_endpoint_502(): - engine = make_engine(env={}) # no *_ENDPOINT set - status, body = engine.dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert status == 502 and body["reason"] == "provider endpoint not configured" - - -def test_shutter_does_not_send(monkeypatch): - _mock_send(monkeypatch, raise_error=AssertionError("should not send")) - status, body = make_engine().dispatch(dispatch_request(), "infobip", True, "r", CapturingLog()) - assert status == 200 and body["shutterProcessed"] is True - - -def test_success_renders_code_and_keeps_privacy(monkeypatch): - capture = {} - _mock_send(monkeypatch, response=FakeResponse(200, {"messages": [{"status": {"name": "DELIVERED"}, "messageId": "x"}]}), capture=capture) - log = CapturingLog() - status, body = make_engine().dispatch(dispatch_request(), "infobip", False, "r", log) - - assert status == 200 and body["status"] == "accepted" - assert "918273" in capture["data"] # the message (with the code) IS sent to the provider (that's the delivery) - serialized = json.dumps(body) - assert "918273" not in serialized and "5551234567" not in serialized # never in the response body - assert all("918273" not in line and "5551234567" not in line for line in log.lines) # never logged - - -def test_unknown_status_fails_closed(monkeypatch): - _mock_send(monkeypatch, response=FakeResponse(200, {"messages": [{"status": {"name": "WATWAT"}}]})) - status, body = make_engine().dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert body["outcome"] == "Fail" and body["status"] == "failed" - - -def test_timeout_maps_to_504(monkeypatch): - _mock_send(monkeypatch, raise_error=dispatch_module.requests.exceptions.Timeout()) - status, _ = make_engine().dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert status == 504 - - -def test_network_error_maps_to_502(monkeypatch): - _mock_send(monkeypatch, raise_error=dispatch_module.requests.exceptions.ConnectionError()) - status, _ = make_engine().dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert status == 502 + for upstream_status, payload, expected, outcome in cases: + response = Mock(status_code=upstream_status, json=Mock(return_value=payload)) + send = Mock(return_value=response) + monkeypatch.setattr(dispatch_module.requests, "request", send) + status, body = engine.dispatch(_request(), "r") + assert (status, body["outcome"], body["provider"]) == (expected, outcome, "soprano") + send.assert_called_once() + response.close.assert_called_once() + + +def test_transport_failures_and_wrapped_read_timeout(engine, monkeypatch): + errors = dispatch_module.requests.exceptions + for error, expected in ((errors.Timeout("offline"), 504), (errors.ConnectionError("offline"), 502)): + send = Mock(side_effect=error) + monkeypatch.setattr(dispatch_module.requests, "request", send) + status, body = engine.dispatch(_request(), "r") + assert status == expected and body["outcome"] == "Fail" + send.assert_called_once() + + # requests can wrap a streamed body-read timeout in ConnectionError. + wrapped = errors.ConnectionError(ReadTimeoutError(None, "https://provider.example", "offline")) + response = Mock(status_code=200, json=Mock(side_effect=wrapped)) + send = Mock(return_value=response) + monkeypatch.setattr(dispatch_module.requests, "request", send) + status, body = engine.dispatch(_request(), "r") + assert status == 504 and body["reason"] == "provider timeout" + send.assert_called_once() + response.close.assert_called_once() diff --git a/python/tests/test_envelope.py b/python/tests/test_envelope.py deleted file mode 100644 index 091c704..0000000 --- a/python/tests/test_envelope.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Envelope validation + JWE decryption round-trip (see docs/CONTRACT.md §1, §6).""" -import json - -from jwcrypto import jwe, jwk - -from src.dispatch import ( - context_to_dispatch, - decrypt_delivery_context, - parse_envelope, -) - -# Throwaway RSA key: encrypt here, decrypt via the module using the private PEM. -_KEY = jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") -_PRIVATE_PEM = _KEY.export_to_pem(private_key=True, password=None).decode("utf-8") - - -def _encrypt(context, kid="test-key"): - protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": kid} - token = jwe.JWE(json.dumps(context).encode("utf-8"), protected=json.dumps(protected)) - token.add_recipient(_KEY) - return token.serialize(compact=True) - - -def _key_provider(_kid): - return _PRIVATE_PEM - - -def _sample_context(): - return {"nonce": "nonce-1", "phoneNumber": "+14255551234", "message": "Your code is 123456", "locale": "en-US"} - - -def test_missing_encrypted_context_is_error(): - envelope, error = parse_envelope({"channel": 1, "mode": 1}) - assert envelope is None - assert "encryptedDeliveryContext" in error - - -def test_unsupported_channel_is_error(): - envelope, error = parse_envelope({"channel": 9, "mode": 1, "encryptedDeliveryContext": "x"}) - assert envelope is None - assert "channel" in error - - -def test_unsupported_mode_is_error(): - envelope, error = parse_envelope({"channel": 1, "mode": 5, "encryptedDeliveryContext": "x"}) - assert envelope is None - assert "mode" in error - - -def test_valid_envelope_parses(): - envelope, error = parse_envelope({ - "type": "microsoft.mfa.otpDeliver.v1", "tenantId": "t", "correlationId": "c", - "channel": 2, "mode": 1, "ttlSeconds": 60, "encryptedDeliveryContext": "x", - }) - assert error is None - assert envelope["channel"] == 2 - assert envelope["mode"] == 1 - - -def test_jwe_round_trips_to_delivery_context(): - compact = _encrypt(_sample_context()) - header, context = decrypt_delivery_context(compact, _key_provider) - assert header["kid"] == "test-key" - assert header["alg"] == "RSA-OAEP-256" - assert header["enc"] == "A256GCM" - assert context["nonce"] == "nonce-1" - assert context["phoneNumber"] == "+14255551234" - assert context["message"] == "Your code is 123456" - - -def test_context_to_dispatch_maps_fields(): - envelope, _ = parse_envelope({ - "correlationId": "corr-1", "channel": 2, "mode": 1, "encryptedDeliveryContext": "x", - }) - dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") - assert dispatch.destination == "+14255551234" - assert dispatch.channel == "voice" - assert dispatch.message_id == "msg-1" - assert dispatch.correlation_id == "corr-1" - # Voice must read the passcode digit by digit. - assert "1 2 3 4 5 6" in dispatch.message - - -def test_sms_message_is_left_intact(): - envelope, _ = parse_envelope({"channel": 1, "mode": 1, "encryptedDeliveryContext": "x"}) - dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") - assert dispatch.message == "Your code is 123456" - - -def test_base64_wrapped_key_is_accepted(): - """The setup script stores EPP_DECRYPTION_KEY_PEM as base64 over the PEM.""" - import base64 as _b64 - wrapped = _b64.b64encode(_PRIVATE_PEM.encode("utf-8")).decode("ascii") - _header, context = decrypt_delivery_context(_encrypt(_sample_context()), lambda _kid: wrapped) - assert context["nonce"] == "nonce-1" diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index 87d6526..8c9ae30 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -1,124 +1,197 @@ -"""Trigger-level tests for the SendOtp HTTP handler. - -The import + route assertions are the regression guard for module-level breakage: a bad `from src...` -line makes the whole Function App fail to start, and the engine-level tests never import this module, -so they stay green while nothing can run. -""" +import base64 +import hashlib import json -import os +import logging +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Event +from unittest.mock import Mock import azure.functions as func import pytest from jwcrypto import jwe, jwk +import function_app import src.dispatch as dispatch_module -# Set before importing function_app: it builds its engine and key provider at module load. -_KEY = jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") -os.environ["EPP_DECRYPTION_KEY_PEM"] = _KEY.export_to_pem(private_key=True, password=None).decode("utf-8") -os.environ["EPP_PROVIDER_NAME"] = "infobip" -os.environ["EPP_PROVIDER_ENDPOINT"] = "https://api.infobip.com" - -import function_app # noqa: E402 - -# Resolved once: app.get_functions() rebuilds bindings and rejects a second call. -_FUNCTIONS = function_app.app.get_functions() -_HANDLER = _FUNCTIONS[0].get_user_function() - - -class _FakeSecrets: - def resolve(self, name): - return "ib" - - -class _FakeResponse: - def __init__(self, status_code, body): - self.status_code = status_code - self._body = body - - def json(self): - return self._body - +_KEY = jwk.JWK.generate(kty="RSA", size=2048) +_PRIVATE_PEM = _KEY.export_to_pem(private_key=True, password=None).decode() +_CORRELATION = "2b65f5e5-9628-4894-8ba6-8785c3a9c010" +_NONCE = "test-nonce" +_PHONE = "+14255551234" +_MESSAGE = " Your code is 123456; keep 7890 unchanged.\nCafé. " +_CONTEXT = {"nonce": _NONCE, "phoneNumber": _PHONE, "message": _MESSAGE, "locale": "en-US"} +_FIXTURES = json.loads((Path(__file__).resolve().parents[2] / "tests/fixtures/contract.json").read_text(encoding="utf-8")) -class _InlineThread: - """Runs the background delivery inline so assertions don't race the worker thread.""" - - def __init__(self, target=None, name=None, daemon=None): - self._target = target - - def start(self): - self._target() +# get_functions() cannot be called twice on the same app. +_HANDLER = function_app.app.get_functions()[0].get_user_function() @pytest.fixture(autouse=True) -def _wire(monkeypatch): - monkeypatch.setattr(function_app._engine, "secrets", _FakeSecrets()) - monkeypatch.setattr(function_app.threading, "Thread", _InlineThread) - - -def _request(body): - raw = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8") - return func.HttpRequest(method="POST", url="/api/SendOtp", headers={}, params={}, body=raw) +def _isolate(monkeypatch): + monkeypatch.delenv("EPP_ENCRYPTION_KEY_ID", raising=False) + monkeypatch.setenv("EPP_PROVIDER_NAME", "SOPRANO") + monkeypatch.setattr(function_app, "_key_provider", Mock(return_value=_PRIVATE_PEM)) + 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"}, + ) + monkeypatch.setattr(function_app, "_engine", engine) + monkeypatch.setattr(dispatch_module.requests, "request", Mock()) + + +def _request(body, headers=None): + raw = body if isinstance(body, bytes) else json.dumps(body).encode() + return func.HttpRequest(method="POST", url="/api/SendOtp", headers=headers or {}, params={}, body=raw) + + +def _encrypt(alg="RSA-OAEP-256", kid="test-kid", enc="A256GCM", context=None): + token = jwe.JWE(json.dumps(_CONTEXT if context is None else context).encode(), + protected=json.dumps({"alg": alg, "enc": enc, "kid": kid})) + token.add_recipient(_KEY) + return token.serialize(compact=True) def _envelope(**overrides): - context = { - "nonce": "nonce-abc", - "phoneNumber": "+14255551234", - "locale": "en-US", - "message": "Your code is 123456", - } - protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": "test-key"} - token = jwe.JWE(json.dumps(context).encode("utf-8"), protected=json.dumps(protected)) + payload = {"type": "microsoft.mfa.otpDeliver.v1", + "correlationId": _CORRELATION, "channel": 1, "mode": 1, "ttlSeconds": 60} + payload.update(overrides) + if "encryptedDeliveryContext" not in payload: + payload["encryptedDeliveryContext"] = _encrypt() + return payload + + +def test_jwe_tag_tampering_and_missing_segments_fail_before_provider_io(monkeypatch, caplog): + monkeypatch.setenv("EPP_ENCRYPTION_KEY_ID", "configured-key-id") + segments = _encrypt().split(".") + tag = segments[-1] + segments[-1] = ("A" if tag[0] != "A" else "B") + tag[1:] + for compact in (".".join(segments), ".".join(segments[:4])): + response = _HANDLER(_request(_envelope(encryptedDeliveryContext=compact))) + assert response.status_code == 400 and json.loads(response.get_body())["error"] == "decryption_failed" + assert not any(record.getMessage() == "encryption_key_id_mismatch" for record in caplog.records) + function_app._engine.secrets.resolve.assert_not_called() + dispatch_module.requests.request.assert_not_called() + + +def test_shared_invalid_requests_return_safe_reasons_before_provider_io(): + valid = _envelope(encryptedDeliveryContext="unused") + for fixture in _FIXTURES["badRequests"]: + payload = fixture["rawBody"].encode() if "rawBody" in fixture else {**valid, **fixture["overrides"]} + response = _HANDLER(_request(payload)) + result = json.loads(response.get_body()) + assert response.status_code == 400 and result["requestId"] + assert result == {"error": "bad_request", "reason": fixture["reason"], + "requestId": result["requestId"]}, fixture["name"] + for changes in _FIXTURES["incompleteContexts"]: + payload = _envelope(mode=2, encryptedDeliveryContext=_encrypt(context={**_CONTEXT, **changes})) + response = _HANDLER(_request(payload)) + result = json.loads(response.get_body()) + assert response.status_code == 400 + assert result == {"error": "bad_request", "reason": "incomplete delivery context", + "correlationId": _CORRELATION, "requestId": result["requestId"]} + function_app._engine.secrets.resolve.assert_not_called() + dispatch_module.requests.request.assert_not_called() + + +def test_shared_jwe_policy_permits_only_rsa_oaep_256_with_a256gcm(): + for fixture in _FIXTURES["jwe"]: + compact = _encrypt(alg=fixture["alg"], enc=fixture["enc"]) + response = _HANDLER(_request(_envelope(mode=2, encryptedDeliveryContext=compact))) + result = json.loads(response.get_body()) + assert response.status_code == (200 if fixture["accepted"] else 400) + if fixture["accepted"]: + assert result["nonce"] == _NONCE + else: + assert result == {"error": "decryption_failed", "correlationId": _CORRELATION, + "requestId": result["requestId"]} + function_app._engine.secrets.resolve.assert_not_called() + dispatch_module.requests.request.assert_not_called() + + +def test_jwe_authenticates_original_protected_header_bytes(): + header = '{ "kid" : "test-key", "enc" : "A256GCM", "alg" : "RSA-OAEP-256" }' + token = jwe.JWE(json.dumps(_CONTEXT).encode(), protected=header) token.add_recipient(_KEY) - - envelope = { - "type": "microsoft.mfa.otpDeliver.v1", - "tenantId": "tenant-1", - "correlationId": "corr-1", - "channel": 1, - "mode": 1, - "ttlSeconds": 60, - "encryptedDeliveryContext": token.serialize(compact=True), + segments = token.serialize(compact=True).split('.') + original = base64.urlsafe_b64encode(header.encode()).decode().rstrip('=') + assert segments[0] == original + response = _HANDLER(_request(_envelope(mode=2, encryptedDeliveryContext='.'.join(segments)))) + assert response.status_code == 200 and json.loads(response.get_body())["nonce"] == _NONCE + segments[0] = base64.urlsafe_b64encode(json.dumps(json.loads(header), separators=(',', ':')).encode()).decode().rstrip('=') + response = _HANDLER(_request(_envelope(mode=2, encryptedDeliveryContext='.'.join(segments)))) + assert response.status_code == 400 and json.loads(response.get_body())["error"] == "decryption_failed" + function_app._engine.secrets.resolve.assert_not_called() + dispatch_module.requests.request.assert_not_called() + + +def test_evaluation_decrypts_without_provider_configuration_or_work(monkeypatch, caplog): + caplog.set_level(logging.INFO) + monkeypatch.setenv("EPP_ENCRYPTION_KEY_ID", "configured-key-id") + monkeypatch.delenv("EPP_PROVIDER_NAME") + function_app._engine.env.clear() + lookup = Mock() + monkeypatch.setattr(function_app._registry, "get", lookup) + response = _HANDLER(_request(_envelope(mode="Evaluation", provider="untrusted-body-provider"))) + assert response.status_code == 200 + assert json.loads(response.get_body()) == { + "nonce": _NONCE, "correlationId": _CORRELATION, "providerStatus": "accepted", } - envelope.update(overrides) - return envelope - - -def test_app_imports_and_registers_the_route(): - assert [f.get_function_name() for f in _FUNCTIONS] == ["send_otp"] - - -def test_invalid_json_is_400(): - response = _HANDLER(_request(b"{ not json")) - assert response.status_code == 400 - - -def test_live_envelope_echoes_the_nonce(monkeypatch): - sent = {} - - def fake_request(method, url, headers=None, data=None, timeout=None): - sent["url"] = url - return _FakeResponse(200, {"messages": [{"status": {"groupName": "PENDING"}, "messageId": "x"}]}) - - monkeypatch.setattr(dispatch_module.requests, "request", fake_request) - + function_app._key_provider.assert_called_once_with("test-kid") + warnings = [record.getMessage() for record in caplog.records if record.levelno == logging.WARNING] + assert warnings == ["encryption_key_id_mismatch"] + 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() + dispatch_module.requests.request.assert_not_called() + + +def test_live_acceptance_waits_and_preserves_wire_data_but_not_plaintext_logs(monkeypatch, caplog): + caplog.set_level(logging.INFO) + monkeypatch.setenv("EPP_LOG_PLAINTEXT", "true") # Must not bypass privacy. + entered, release = Event(), Event() + upstream = Mock(status_code=202, json=Mock(return_value={"status": "ENROUTE"})) + + def wait_for_acceptance(*args, **kwargs): + entered.set() + assert release.wait(5), "test did not release provider acceptance" + return upstream + + 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"}) + pending = executor.submit(_HANDLER, request) + try: + assert entered.wait(5), "handler did not reach provider" + assert not pending.done() + finally: + release.set() + response = pending.result(timeout=5) + assert response.status_code == 200 + assert json.loads(response.get_body()) == { + "nonce": _NONCE, "correlationId": _CORRELATION, "providerStatus": "accepted", + } + 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 + 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"): + assert private not in caplog.text + + +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 == 200 - assert body["nonce"] == "nonce-abc" - assert body["correlationId"] == "corr-1" - assert sent["url"].startswith("https://") - - -def test_evaluation_mode_does_not_send(monkeypatch): - def fail(*args, **kwargs): - raise AssertionError("evaluation mode must not send") - - monkeypatch.setattr(dispatch_module.requests, "request", fail) - - response = _HANDLER(_request(_envelope(mode=2))) - - assert response.status_code == 200 - assert json.loads(response.get_body())["nonce"] == "nonce-abc" + 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() diff --git a/tests/fixtures/contract.json b/tests/fixtures/contract.json new file mode 100644 index 0000000..ab2bbdf --- /dev/null +++ b/tests/fixtures/contract.json @@ -0,0 +1,33 @@ +{ + "jwe": [ + { "alg": "RSA-OAEP-256", "enc": "A256GCM", "accepted": true }, + { "alg": "RSA-OAEP", "enc": "A256GCM", "accepted": false }, + { "alg": "RSA-OAEP-256", "enc": "A128GCM", "accepted": false }, + { "alg": "RSA-OAEP-256", "enc": "A256CBC-HS512", "accepted": false } + ], + "badRequests": [ + { "name": "invalid JSON", "rawBody": "{", "reason": "invalid JSON body" }, + { "name": "array envelope", "rawBody": "[]", "reason": "invalid envelope" }, + { "name": "null envelope", "rawBody": "null", "reason": "invalid envelope" }, + { "name": "wrong version", "overrides": { "type": "wrong" }, "reason": "unsupported envelope type" }, + { "name": "boolean channel", "overrides": { "channel": true }, "reason": "unsupported channel" }, + { "name": "numeric string channel", "overrides": { "channel": "1" }, "reason": "unsupported channel" }, + { "name": "null mode", "overrides": { "mode": null }, "reason": "unsupported mode" }, + { "name": "empty encryption", "overrides": { "encryptedDeliveryContext": "" }, "reason": "encryptedDeliveryContext is required" }, + { "name": "blank encryption", "overrides": { "encryptedDeliveryContext": " " }, "reason": "encryptedDeliveryContext is required" }, + { "name": "string TTL", "overrides": { "ttlSeconds": "60" }, "reason": "invalid ttlSeconds" }, + { "name": "null TTL", "overrides": { "ttlSeconds": null }, "reason": "invalid ttlSeconds" }, + { "name": "boolean TTL", "overrides": { "ttlSeconds": true }, "reason": "invalid ttlSeconds" }, + { "name": "fractional TTL", "overrides": { "ttlSeconds": 1.5 }, "reason": "invalid ttlSeconds" }, + { "name": "oversized TTL", "overrides": { "ttlSeconds": 2147483648 }, "reason": "invalid ttlSeconds" }, + { "name": "expired TTL", "overrides": { "ttlSeconds": 0 }, "reason": "ttlSeconds expired" }, + { "name": "negative TTL", "overrides": { "ttlSeconds": -1 }, "reason": "ttlSeconds expired" }, + { "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" } + ], + "incompleteContexts": [ + { "nonce": "" }, + { "phoneNumber": null }, + { "message": " " } + ] +} \ No newline at end of file