From f707e51bcc1503283d52667610be3bba9920a4ac Mon Sep 17 00:00:00 2001 From: Nisheet Jain Date: Fri, 2 Oct 2026 13:42:28 -0700 Subject: [PATCH] Simplify JavaScript provider flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0769c5c6-97a7-4645-ab83-5cda60c73b16 --- docs/CONTRACT.md | 151 ++- javascript/README.md | 50 +- javascript/src/functions/SendOtp.js | 313 ++++-- javascript/src/functions/credentials.js | 23 +- javascript/src/functions/delivery.js | 43 + javascript/src/functions/dispatch.js | 426 -------- javascript/src/functions/entraPayload.js | 64 ++ javascript/src/functions/jwe.js | 52 + javascript/src/functions/logging.js | 147 +++ javascript/src/functions/models.js | 96 -- javascript/src/functions/providerResult.js | 40 + javascript/src/functions/providerTransport.js | 111 +++ javascript/src/functions/providers/index.js | 18 + javascript/src/functions/providers/infobip.js | 131 +-- javascript/src/functions/providers/sinch.js | 132 +-- javascript/src/functions/providers/soprano.js | 138 +-- .../src/functions/providers/telesign.js | 143 ++- javascript/src/functions/requestLog.js | 234 ----- javascript/test/credential-cache.test.js | 24 +- javascript/test/credential-sdk.test.js | 22 +- javascript/test/dispatch.test.js | 351 ------- javascript/test/provider-flow.test.js | 297 ++++++ javascript/test/sendotp.test.js | 909 +++++++----------- 23 files changed, 1816 insertions(+), 2099 deletions(-) create mode 100644 javascript/src/functions/delivery.js delete mode 100644 javascript/src/functions/dispatch.js create mode 100644 javascript/src/functions/entraPayload.js create mode 100644 javascript/src/functions/jwe.js create mode 100644 javascript/src/functions/logging.js delete mode 100644 javascript/src/functions/models.js create mode 100644 javascript/src/functions/providerResult.js create mode 100644 javascript/src/functions/providerTransport.js create mode 100644 javascript/src/functions/providers/index.js delete mode 100644 javascript/src/functions/requestLog.js delete mode 100644 javascript/test/dispatch.test.js create mode 100644 javascript/test/provider-flow.test.js diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 57ec15c..b1b8467 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -4,11 +4,12 @@ This defines the shared contract for [JavaScript](../javascript/), [Python](../p [.NET](../dotnet/). See [production limitations](#production-limitations) before production use. > **Naming.** EPP means **External Phone Provider**. App settings use the `EPP_` prefix; the -> request and delivery models are `Envelope`, `DeliveryContext` and `DispatchRequest`. +> request and delivery models are `Envelope`, `DeliveryContext` and provider-neutral OTP delivery. > Documentation names do not change the external JSON fields or `microsoft.mfa.otpDeliver.v1` version. -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. +The design selects one provider per deployment. The HTTP Function owns the request lifecycle, while +API-specific credentials, paths, headers, payloads, response rules, outcomes and safe failure +classifications remain inside each provider implementation. --- @@ -82,15 +83,15 @@ runs once per language. Tag tampering and original-header-byte tests remain. |-------|----------|-------| | `nonce` | yes | value the endpoint MUST echo to prove decryption | | `phoneNumber` | yes | caller supplies an E.164 string; full E.164 validation is an implementation gap | -| `message` | yes | fully rendered, localized text containing the passcode; text-message adapters forward it unchanged, Soprano voice extracts the first six consecutive digits, and Telesign voice paces standalone six-digit numeric runs and repeats the full message twice | +| `message` | yes | fully rendered, localized text containing the passcode; text-message providers forward it unchanged, Soprano voice extracts the first six consecutive digits, and Telesign voice paces standalone six-digit numeric runs and repeats the full message twice | | `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 | +| `locale` | no | voice selection input where supported by the selected provider | | `riskContext` | no | contextual request data; no risk-policy evaluation is implemented here | -| `textToVoice` | no | legacy structured speech input; current Soprano adapters ignore it | +| `textToVoice` | no | legacy structured speech input; current Soprano implementations ignore it | Decryption failure → `400`. Missing `nonce` / `phoneNumber` / `message` → `400`. -For Soprano live voice, the adapter finds the first six consecutive digits in `message`, preserving +For Soprano live voice, the provider finds the first six consecutive digits in `message`, preserving leading zeros, and splits the rendered text into `beforePasswordText`, `password`, and `afterPasswordText`. It uses a nonblank SAS `locale` as `language`, falling back to `en-US`, and sends fixed `gender: 1` and `loop: 2`. The resulting object is sent as `voice.text2voice` without a top-level @@ -105,8 +106,8 @@ requests the configured provider scope. Existing platform caller authentication The Function obtains one provider token through the shared OAuth credential resolver using the setup-generated `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE`, `EPP_OUTBOUND_CLIENT_ID`, and -`EPP_OUTBOUND_MI_CLIENT_ID`. `EPP_PROVIDER_AUTH_MODE=oauth` matches the Soprano adapter. -`EPP_PROVIDER_ENDPOINT` is the complete selected send URL and is not modified by the adapter. +`EPP_OUTBOUND_MI_CLIENT_ID`. `EPP_PROVIDER_AUTH_MODE=oauth` matches the Soprano provider. +`EPP_PROVIDER_ENDPOINT` is the complete selected send URL and is not modified by the provider. The calling app registration and outbound user-assigned identity must share a home tenant; the calling app must be multitenant and provisioned/authorized in the provider tenant. The app's federated credential trusts the identity's principal ID, home-tenant v2 issuer, and @@ -146,11 +147,11 @@ See [Microsoft's managed-identity federation guidance](https://learn.microsoft.c Use a SAS locale supported by the selected Soprano endpoint and account. On QA4, an API-key voice request using `en` returned HTTP `400` with error code `400101`; the same request structure using `en-US` returned HTTP `201` with `ENROUTE` on September 15, 2026. This confirms acceptance, -not handset receipt or audio quality. When SAS omits the locale, the adapters use `en-US`. +not handset receipt or audio quality. When SAS omits the locale, the providers use `en-US`. The supplied Soprano Connect Voice PDF describes a different API: `POST /voice/voice_orderApiCreate.do` with form-encoded fields, `subAction=20`, and numeric language IDs (`1` is default English). -Its password fields are `beforePassword`, `passwordText`, and `afterPassword`. This adapter follows +Its password fields are `beforePassword`, `passwordText`, and `afterPassword`. This provider follows the reference integration's JSON `/messages/omnimsg` contract instead; do not mix the form API's language IDs, field names, or `ApiResponse.StatusCode` response format with this JSON interface. @@ -203,8 +204,8 @@ wire fields, where required by an API, remain internal and cannot enable a separ ## 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`. +The provider interprets its parsed status as an **outcome** and endpoint HTTP status. +**Fail-closed:** an unknown/unmapped or incomplete response 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. @@ -222,46 +223,38 @@ success-looking status. Explicit `Block`/`StepUp` outcomes remain non-success re --- -## 3. Provider adapter contract +## 3. Provider contract -Each provider is one unit exposing three things: +Each provider owns: -- **`manifest`**: protocol facts only: - - `id`: provider id selected by `EPP_PROVIDER_NAME`; its complete request URL is `EPP_PROVIDER_ENDPOINT` - - `auth`: either `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }` or - `{ mode: 'oauth' }`; unsupported modes fail closed - - `responseMapping`: map of provider status → `Continue` | `Fail` | `Block` | `StepUp` (+ `default`) -- **`buildRequest({ channel, endpoint, dispatch, credential, env })`** → `{ url, method, headers, body }` -- **`parseResponse({ httpStatus, ok, json })`** → `ParsedResponse`, containing `success`, - `providerHttpStatus`, optional `providerMessageId`, `providerStatusName`, `providerStatusCode` - and `providerStatusDescription` (snake_case attributes in Python, PascalCase in .NET). +- its fixed name, authentication mode and credential acquisition specification; +- request construction, including provider-specific paths, headers and JSON; +- response shape interpretation, recognized statuses, outcome and endpoint HTTP status; +- safe failure classification: `provider_http_error`, `provider_rejected`, + `unrecognized_provider_status`, `missing_provider_message_id` or `invalid_provider_json`. -The adapter reads its API-specific JSON and constructs a normalized `ParsedResponse` object: -[JavaScript](../javascript/src/functions/models.js), [Python](../python/src/models.py), -[.NET](../dotnet/Src/Models.cs). The engine reads named properties/attributes rather than provider JSON -or string-key response dictionaries. Optional values default to null/None; a status name takes precedence -over a code during outcome mapping, as before. Custom Python adapters must return `ParsedResponse`, -not the former dictionary. +The shared transport owns only final HTTPS validation, a capped timeout, manual redirects, +`AbortController`/cancellation through response-body reading and one JSON parse. The provider receives +the transport result and returns a normalized provider result. Raw API-specific JSON is not inspected +by the Function and never enters public responses or logs. This model is internal: do not serialize it into the endpoint response or logs. The -[request logger](#application-logs) selects only the provider HTTP status, a status found in the -adapter's mapping, the mapped outcome and a bounded raw provider message ID; descriptions and raw +[request logger](#application-logs) selects only the provider HTTP status, a recognized normalized +status, the mapped outcome and a bounded raw provider message ID; descriptions and raw metadata remain private. Public HTTP responses still expose only the existing nonce/correlation/status or sanitized error contract. Provider requests are serialized only when building the outbound HTTP body; incoming provider JSON -is parsed once and normalized inside its adapter. No serialization framework or provider-specific +is parsed once and normalized inside its provider. No serialization framework or provider-specific class hierarchy is required. -Adapters require registration in the chosen runtime. Consult the selected adapter and its manifest -for required credentials and options: the manifest declares authentication and protocol mappings; -the implementation reads adapter-specific options from app settings. Individual API contracts remain -in the adapters; the [onboarding credential naming table](ONBOARDING.md#provider-credential-names) -lists the exact manifest secret names for provisioning and authorized local tests. Keep that table -aligned with the manifests; never include secret values in documentation or the settings sample. +Consult the selected provider implementation for required credentials and options. The +[onboarding credential naming table](ONBOARDING.md#provider-credential-names) lists exact secret +names for provisioning and authorized local tests. Keep that table aligned with provider credential +specifications; never include secret values in documentation or the settings sample. ### Telesign EPP integration -SMS and Voice use the complete provider-approved URLs selected from the provider profile. The adapter +SMS and Voice use the complete provider-approved URLs selected from the provider profile. The provider supplies `recipient.phone_number`, the unchanged `message.text`, optional `message.language`, one selected `channels[].channel`, and `correlation_id`. Keep the leading `+` in the E.164 phone number. @@ -278,7 +271,7 @@ account events to fill them. Telesign's `X-Shutter-Mode: true` suppresses delivery at the provider while still calling its endpoint. It is appropriate for an explicitly authorized, direct provider diagnostic, not normal OTP delivery. -The production adapter does not add or forward this header. Function evaluation mode remains separate: +The production provider does not add or forward this header. Function evaluation mode remains separate: it validates/decrypts and skips all provider HTTP. A successful provider shutter probe is not evidence that an SMS was delivered or a Voice call was placed. Enabling the API globally also does not prove that a particular Customer ID/API Key pair is authorized for this integration. @@ -291,22 +284,22 @@ Set by provisioning. **Identical names across all languages.** | Key | Purpose | |-----|---------| -| `EPP_PROVIDER_NAME` | registered id of the selected provider; `` is a placeholder, not a bundled default | +| `EPP_PROVIDER_NAME` | fixed id of the selected provider; `` is a placeholder, not a bundled default | | `EPP_PROVIDER_ENDPOINT` | complete absolute HTTPS request URL for the selected channel/region, with a hostname, port 1–65535, and no userinfo or fragment; redirects are not followed | | `EPP_PROVIDER_CHANNEL` | optional configured `sms` or `voice` route; when set, other live-request channels fail closed | | `EPP_PROVIDER_ENDPOINT_REGION` | selected `global` or `eu` route label; informational at runtime | -| `EPP_PROVIDER_AUTH_MODE` | must match the selected adapter (`apiKey` for Telesign, `oauth` for Soprano) | +| `EPP_PROVIDER_AUTH_MODE` | must match the selected provider (`apiKey` for Telesign, `oauth` for Soprano) | | `EPP_PROVIDER_TENANT_ID` | selected provider tenant; added to the Step 1 app's allowed-tenants preview and used as the OAuth authority for Soprano | | `EPP_PROVIDER_SCOPE` | Soprano OAuth scope | | `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | client application and user-assigned identity used for Soprano client-assertion exchange | -| `EPP_PROVIDER_ACCOUNT_NAME` | sender/source only when required by the selected adapter | +| `EPP_PROVIDER_ACCOUNT_NAME` | sender/source only when required by the selected provider | | `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 for API-key providers | | `AZURE_CLIENT_ID` | set for a user-assigned managed identity | -Telesign credentials live in **Key Vault**, under the names in its manifest, and are fetched via +Telesign credentials live in **Key Vault**, under the names in its credential specification, and are fetched via managed identity. Soprano exchanges an outbound managed-identity assertion for a token in the configured provider tenant/scope. Do not put provider secrets in code or app settings. @@ -326,16 +319,16 @@ The shared configuration readers are [JavaScript `readConfig`](../javascript/src [Python `read_config`](../python/src/config.py), and [.NET `AppConfig.Read`](../dotnet/Src/AppConfig.cs). They return named configuration objects for encryption and the selected provider, not caller-authentication settings. Key Vault settings are read by JavaScript's configuration object and by the Python/.NET secret resolvers. -Provider-specific options remain ordinary app settings passed to the selected adapter. +Provider-specific options remain ordinary app settings passed to the selected provider. JSON parsing and type checks stay at the request boundary. Downstream code uses `Envelope`, -`DeliveryContext` and `DispatchRequest` models (documented object shapes in JavaScript, dataclasses +`DeliveryContext` and provider-neutral delivery models (immutable JavaScript objects, dataclasses in Python, and classes/records in .NET). Named .NET response records preserve the existing wire names and optional-field omission. Object construction does not replace validation or coerce invalid input. -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, +All customers call the same `POST /api/SendOtp` handler in their chosen language. A fixed lookup selects +the configured provider, which builds the SMS or voice API call. Purchasing an unsupported +provider does not install an implementation: add that provider first. Purchase, subscription activation and changing tenant policy belong to provisioning, not this Function. ### Credential caching and refresh @@ -345,11 +338,11 @@ when credentials are fetched, not the HTTP/nonce contract, caller authentication selection or provider request format. The decryption-key Key Vault reference remains separate and is still resolved by the platform; this cache does not rotate or replace JWE keys. -The selected provider's manifest determines which of two concrete cache classes is created: +The selected provider's credential specification determines which concrete cache is created: | Authentication mode | Cache | Acquisition | |---|---|---| -| `apiKey` | `ApiKeyCache` | Fetch the manifest's Key Vault secrets using managed identity. Cache the complete key/customer-ID bundle in .NET `MemoryCache`, JavaScript `lru-cache`, or Python `cachetools.TTLCache`. | +| `apiKey` | `ApiKeyCache` | Fetch the provider's Key Vault secrets using managed identity. Cache the complete key/customer-ID bundle in .NET `MemoryCache`, JavaScript `lru-cache`, or Python `cachetools.TTLCache`. | | `oauth` | `AccessTokenCache` | Reuse Azure Identity's managed-identity and client-assertion credentials and their SDK caches. Retain only the latest usable provider token. No Key Vault access. | Only the selected cache starts. Its credential configuration is bound on first use; app-setting @@ -390,8 +383,8 @@ Per-request `providerCredentialElapsedMs` continues to measure the caller's reso set, else system-assigned). No static credentials. - **Privacy**: never log phone numbers, passcodes, nonce values, bearer tokens, API keys, JWE headers/payloads, raw exceptions, provider descriptions/responses or endpoint query strings. There is no plaintext diagnostic - override. Each handler emits separate service events. JavaScript and Python also emit one - [request summary](#application-logs); .NET uses standard structured `ILogger` events and scopes instead. + override. Each handler emits separate service events. JavaScript and .NET use fixed completion + events rather than a mutable comprehensive summary; Python retains its safe summary. Generated Function IDs remain distinguished from raw Microsoft/provider support IDs. Original wire IDs and the required nonce echo remain unchanged. Support IDs can correlate customer activity; restrict log access and retention. Endpoint logs contain only scheme, host/port and API path, never userinfo, @@ -415,19 +408,22 @@ Per-request `providerCredentialElapsedMs` continues to measure the caller's reso ### Application logs JavaScript and Python emit JSON records with the shared fields described below. Service events have -`logType: "service"` and an individual `eventName`; each invocation ends with one -`logType: "request"`, `eventName: "request_completed"` summary. +`logType: "service"` and an individual `eventName`; each invocation ends with a fixed +`request_completed` event. JavaScript uses an immutable request context and does not build a mutable +comprehensive request summary. .NET uses the standard `ILogger` pipeline instead of manually serializing JSON. `OtpLog` defines source-generated events with stable IDs and names, while `ILogger.BeginScope` supplies `FunctionName`, `FunctionRequestId`, `FunctionInvocationId`, `MsClientRequestId`, `MsCorrelationId` and `MsCorrelationIdSource`. The configured logging provider owns output formatting and export. .NET emits `request_completed` as an ordinary typed event rather than a -mutable comprehensive summary. +mutable comprehensive summary. JavaScript follows the same fixed-event model while retaining its +existing JSON field names. -A successful .NET live request emits: +A successful .NET or JavaScript live request emits: -`request_received`, `payload_validated`, `delivery_context_decrypted`, `provider_selected`, +`request_received`, `payload_validated` (`envelope_validated` in JavaScript), +`delivery_context_decrypted`, `provider_selected`, `provider_credential_resolution_started`, `provider_credential_resolved`, `provider_request_build_started`, `provider_request_built`, `provider_request_started`, `provider_response_received`, `provider_response_processed`, `response_prepared`, @@ -445,8 +441,7 @@ bodies, decrypted delivery fields, credentials, provider response bodies, query exception messages. Endpoint values contain only scheme, host/port and path. Evaluation omits all provider events and emits `evaluation_completed`. -A successful JavaScript or Python live request emits these separate service events, followed by the -request summary: +A successful JavaScript live request emits these separate service events: | Service event | Safe information recorded | |---|---| @@ -456,7 +451,7 @@ request summary: | `provider_selected` | Registered provider and its authentication mode. | | `provider_credential_resolution_started` | OAuth client-assertion or Key Vault credential source, with explicitly named raw OAuth application/identity/tenant IDs. | | `provider_credential_resolved` | Credential resolution and usability checks completed, with elapsed time; no secret, token, assertion or claims. | -| `provider_request_build_started` | The adapter is preparing the outbound request. | +| `provider_request_build_started` | The provider is preparing the outbound request. | | `provider_request_built` | Allowlisted HTTP method, final endpoint scheme/host/port/API path, HTTPS and disabled redirects; no query string, authorization headers or body. | | `provider_request_started` | The outbound send is beginning, with method, sanitized endpoint and timeout. | | `provider_response_received` | Actual upstream HTTP status; emitted before response-body reading completes. | @@ -473,8 +468,8 @@ a Key Vault network fetch or a fresh identity/token exchange occurred. `provider covers resolution plus the existing credential checks, not a separately enforced deadline. SDK diagnostics remain suppressed as before; logging adds no token acquisition, secret reads or retries. -`provider_request_built` describes the adapter request, not creation of a new physical HTTP connection -or a new HTTP client in every runtime. `providerEndpoint` uses the **final** adapter URL, which can +`provider_request_built` describes the provider request, not creation of a new physical HTTP connection +or a new HTTP client in every runtime. `providerEndpoint` uses the **final** provider URL, which can differ from the configured base URL, but records only scheme + host/port + API path. For example, `https://provider.example/epp/voice?key=secret` is logged as `https://provider.example/epp/voice`. Userinfo, the entire query string and fragments are excluded. Use provider-approved paths that do @@ -485,9 +480,9 @@ values are represented as `other` without changing the request sent. `response_prepared` is emitted on success **and failure** immediately before returning the handler response. It does not claim the host has serialized/transmitted that response or Microsoft received it; consult platform request telemetry for transport completion. Evaluation emits -`evaluation_completed` instead of provider events, then `response_prepared` and the summary, -without resolving provider configuration, credentials or HTTP. Failures emit their own stage event, -such as `decryption_failed`, `provider_credentials_failed` or `provider_transport_failed`. +`evaluation_completed` instead of provider events, then `response_prepared` and `request_completed`, +without resolving provider configuration, credentials or HTTP. Failures emit the fixed +`request_failed` event with a safe stage, reason and HTTP status. A parsed provider rejection uses `provider_response_processed` with its non-success outcome and fixed failure reason. @@ -506,7 +501,7 @@ from header to envelope. The identifiers are intentionally not named simply `req | `x-ms-client-request-id` | Raw Microsoft per-attempt ID from the header of the same name, not the generated fallback used by dispatch. | | `x-ms-correlation-id` | Raw selected Microsoft correlation: envelope `correlationId` first, then the header of the same name. The existing precedence is unchanged, and this never contains a generated Function ID. | | `msCorrelationIdSource` | `envelope`, `header` or `none`; disambiguates the selected value when the envelope and header differ. | -| `providerMessageId` | Raw adapter-normalized message/reference ID returned by the provider, including Telesign `reference_id`, for support lookup. Not filled from dispatch or Function IDs, although a provider may echo an ID it received. | +| `providerMessageId` | Raw provider-normalized message/reference ID returned by the provider, including Telesign `reference_id`, for support lookup. Not filled from delivery or Function IDs, although a provider may echo an ID it received. | | `providerTenantId` | Raw configured `EPP_PROVIDER_TENANT_ID` for OAuth, not incoming envelope `tenantId`. | | `functionOutboundClientId` | Raw application's `EPP_OUTBOUND_CLIENT_ID` used for provider access, not the endpoint application's inbound audience or a Microsoft request ID. | | `functionOutboundManagedIdentityClientId` | Raw configured `EPP_OUTBOUND_MI_CLIENT_ID` used to obtain the app assertion, not the Key Vault identity or the identity's principal/Object ID. | @@ -523,17 +518,19 @@ These are tracing fields, not authentication assertions. In particular, an incom does not become a trusted tenant identity in logs. The existing wire correlation precedence, provider request IDs and public responses are unchanged. -The JavaScript and Python request summary contains: +Python retains a comprehensive request summary. JavaScript emits the same safe concepts only on the +fixed event where each value is known; its `request_completed` event contains the final HTTP status, +result and elapsed time rather than a cumulative mutable snapshot: | Fields | Purpose | |---|---| | `httpStatus`, `result`, `elapsedMs` | Final Function response, `accepted` / `evaluated` / `failed`, and total handler time in milliseconds. Acceptance is not handset delivery. | | `envelopeType`, `channel`, `evaluation`, `ttlSeconds` | Allowlisted request-body metadata; null until envelope validation succeeds. Omitted TTL remains null; logging does not introduce expiry enforcement. | -| `providerName`, `providerAuthMode`, `providerAttempted` | Registered adapter ID, its `apiKey` / `oauth` mode, and whether provider HTTP was attempted. Unknown configured names and credentials are never echoed. | +| `providerName`, `providerAuthMode`, `providerAttempted` | Fixed provider ID, its `apiKey` / `oauth` mode, and whether provider HTTP was attempted. Unknown configured names and credentials are never echoed. | | `providerCredentialSource`, `providerCredentialElapsedMs` | Credential resolution path and duration, including failed resolution; null if it never started. | | `providerTenantId`, `functionOutboundClientId`, `functionOutboundManagedIdentityClientId` | Raw configured OAuth identity IDs; null when OAuth resolution was not attempted. | -| `providerHttpMethod`, `providerEndpoint` | Final adapter request method and scheme/host/port/API path, set only after request construction and URL validation. No query string. | -| `providerHttpStatus`, `providerStatus`, `providerOutcome` | Actual upstream HTTP status and normalized response mapping. Status is logged only if it is an explicit adapter mapping key (not `default`); otherwise it is `unmapped`. | +| `providerHttpMethod`, `providerEndpoint` | Final provider request method and scheme/host/port/API path, set only after request construction and URL validation. No query string. | +| `providerHttpStatus`, `providerStatus`, `providerOutcome` | Actual upstream HTTP status and normalized result. Status is logged only when the provider recognized it; otherwise it is `unmapped`. | | `providerMessageId` | Raw provider lookup/reference ID for support escalation. | | `providerElapsedMs`, `providerTimeoutMs` | Outbound request duration including response-body reading, and the configured/clamped HTTP timeout. Neither is an end-to-end deadline. | | `failureStage`, `failureReason` | Stage and fixed diagnostic reason, such as `provider_credentials` / `credential_unavailable`, `provider_transport` / `provider_timeout`, or `provider_response` / `provider_rejected`. No exception messages. | @@ -545,12 +542,12 @@ Provider fields remain null when their stage was not reached. `providerHttpStatu soon as headers arrive, so a response-body timeout can legitimately show upstream `200` alongside Function `httpStatus: 504`, without a mapped provider status or success acknowledgement. Unknown provider status text and malformed JSON are never logged; malformed JSON emits only -`provider_response_invalid_json` before the existing adapter outcome rules run. +`provider_response_invalid_json` before the provider outcome rules run. -Normal events and request summaries use Information; invalid requests, non-success 4xx outcomes and +Normal events and completion events use Information; invalid requests, non-success 4xx outcomes and advisory warnings use Warning; 5xx failures and timeouts use Error. Keep application Information logs enabled when investigating. This contract describes **emission**, not guaranteed collection: -host/telemetry filters and sampling can drop trace records. Application summaries are logs, not +host/telemetry filters and sampling can drop trace records. Application events are logs, not the host's Request telemetry type, so excluding `Request` from sampling does not by itself retain every summary. Configure collection and retention deliberately without enabling SDK/body tracing. Easy Auth rejections occur before the handler and appear in platform telemetry, not these events. @@ -561,12 +558,12 @@ Easy Auth rejections occur before the handler and appear in platform telemetry, Each language keeps lightweight offline tests covering representative application checks for: -- Bundled adapter request formats and static provider credentials. +- Bundled provider request formats and static provider credentials. - Fail-closed outcomes, missing credentials, HTTPS guards and timeouts. - Envelope validation and real JWE decryption/tamper rejection. - Evaluation handling without provider I/O; configured-provider startup refresh is tested separately. - Awaited delivery, nonce acknowledgement and privacy-safe logging, including the shared - service-event order and summary field set in [contract.json](../tests/fixtures/contract.json), + service-event order and safe field cases in [contract.json](../tests/fixtures/contract.json), identifier provenance, error paths, provider-body timeouts and concurrent request isolation. - Selected-cache-only startup, shared credential retrieval, fixed refresh/retry cadence, hard expiry, token lifetime preservation and shutdown using controlled clocks and diff --git a/javascript/README.md b/javascript/README.md index 81e6c15..577f3db 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -1,18 +1,20 @@ # 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. +A Node.js Azure Function implementing the shared [contract](../docs/CONTRACT.md). The Function owns +an explicit parse → decrypt → evaluation short-circuit or provider selection → credential resolution +→ provider request/transport/result → response flow. One provider is selected per deployment, and +each provider owns its credentials, wire request, response interpretation, outcome and failure class. ## 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 +1. Follow [customer onboarding](../docs/ONBOARDING.md). Choose a bundled provider and set + `EPP_PROVIDER_NAME` to its fixed id; `` is a placeholder, not a default. +2. Consult the selected implementation in [src/functions/providers/](src/functions/providers/) + for required credentials and options. Store credential values under the provider'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 + replacing placeholders with the selected provider'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 @@ -48,7 +50,7 @@ the test-key placeholder in this minimal setup: For live delivery, add `EPP_PROVIDER_NAME`, the complete selected `EPP_PROVIDER_ENDPOINT`, and the matching provider authentication settings to `Values`. -Add `EPP_PROVIDER_ACCOUNT_NAME` and any adapter-specific options only when required. Optional +Add `EPP_PROVIDER_ACCOUNT_NAME` and any provider-specific options only when required. Optional `EPP_PROVIDER_TIMEOUT_MS` is a string such as `"1500"`. Replace placeholders; do not put API keys in this file. See the [complete variable table](../README.md#configure-environment-variables). @@ -61,15 +63,15 @@ does not resolve Key Vault references locally; supply the local test PEM or base Older private settings may contain `DEFAULT_PROVIDER`, `ENDPOINT_TIMEOUT_MS`, `REQUIRE_AUTH`, `EXPECTED_AUDIENCE`, `ISSUER_TENANT_ID`, `EUDB`, or per-provider `*_ENDPOINT` entries. Those do not configure the current shared engine. Use `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and -`EPP_PROVIDER_TIMEOUT_MS` instead; configure caller authentication in Easy Auth. Keep adapter options +`EPP_PROVIDER_TIMEOUT_MS` instead; configure caller authentication in Easy Auth. Keep provider options that are actually read, such as a service-plan ID or voice selection. Private integration helpers may load settings from another location or use test credential variables, but the Function itself does not. -The Soprano adapter uses the configured complete endpoint and an OAuth bearer token. For voice, it +The Soprano provider uses the configured complete endpoint and an OAuth bearer token. For voice, it extracts the first six-digit passcode from the rendered message and sends fixed synthesis values: gender `1` and loop `2`. It uses a nonblank SAS request locale as the language, falling back to `en-US` when the locale is absent or invalid. These values require no additional environment -settings. Soprano SMS continues to forward the rendered message unchanged. The Telesign adapter +settings. Soprano SMS continues to forward the rendered message unchanged. The Telesign provider uses the configured complete endpoint and API-key credentials from Key Vault. Telesign SMS forwards the rendered message unchanged; Telesign voice comma-separates each six-digit numeric run that is not part of a longer number and repeats the complete paced message twice. @@ -99,7 +101,7 @@ retries. The shared contract defines validation, HTTP outcomes and privacy-safe ## Source and extension points -The app-start hook selects `ApiKeyCache` or `AccessTokenCache` from the provider manifest's auth mode. +The app-start hook selects `ApiKeyCache` or `AccessTokenCache` from the provider credential spec. Only that cache starts: API keys use Key Vault and `lru-cache`; access tokens use the MI/Entra SDKs, without Key Vault. One shared 30-second refresh loop and one in-flight acquisition keep warm reads nonblocking. Configuration changes require restart; failures never extend expiry. A small HTTP-client @@ -109,17 +111,21 @@ local evaluation-only use without credential acquisition; prewarming never sends | Source | Purpose | |---|---| -| [src/functions/SendOtp.js](src/functions/SendOtp.js) | HTTP handler | +| [src/functions/SendOtp.js](src/functions/SendOtp.js) | Explicit HTTP orchestration and startup/termination hooks | | [src/functions/config.js](src/functions/config.js) | Shared deployment settings | -| [src/functions/models.js](src/functions/models.js) | Delivery context, normalized `ParsedResponse`, and documented request objects | -| [src/functions/dispatch.js](src/functions/dispatch.js) | Envelope/JWE handling, registry and dispatch | -| [src/functions/credentials.js](src/functions/credentials.js) | `ApiKeyCache`, `AccessTokenCache` and their shared refresh coordinator | -| [src/functions/requestLog.js](src/functions/requestLog.js) | Request-scoped [service events and summaries](../docs/CONTRACT.md#application-logs) with explicit ID sources | -| [src/functions/providers/](src/functions/providers/) | Adapter manifests and API-specific implementations | +| [src/functions/entraPayload.js](src/functions/entraPayload.js) | Validated Entra envelope and exact contract reasons | +| [src/functions/delivery.js](src/functions/delivery.js) | Validated decrypted context and immutable provider-neutral delivery | +| [src/functions/jwe.js](src/functions/jwe.js) | Flat JWE validation/decryption with pinned algorithms and PEM key cache | +| [src/functions/providerTransport.js](src/functions/providerTransport.js) | HTTPS validation, bounded fetch, body read, JSON parse and manual redirects | +| [src/functions/providerResult.js](src/functions/providerResult.js) | Provider result and endpoint outcome model | +| [src/functions/logging.js](src/functions/logging.js) | Immutable request context and fixed privacy-safe structured events | +| [src/functions/credentials.js](src/functions/credentials.js) | `CredentialTokenService`, `ApiKeyCache`, `AccessTokenCache` and refresh lifecycle | +| [src/functions/providers/](src/functions/providers/) | Fixed lookup and provider-owned credential/request/response rules | | [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). Return a `ParsedResponse` from -`parseResponse`; raw API-specific JSON stays inside that adapter. Keep credentials, options and -status mapping with that adapter; the shared pipeline needs no provider-specific branches. See +To add a provider, implement its fixed `name`, `authenticationMode`, `credentialSpec`, +`createRequest` and `interpretResponse` in the provider folder, then add it to the switch in +[providers/index.js](src/functions/providers/index.js). `interpretResponse` returns a +`ProviderResult` containing the provider-owned outcome, endpoint HTTP status and one safe fixed +failure classification. Raw API JSON stays inside the provider. See [production limitations](../docs/CONTRACT.md#production-limitations) before production use. diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index 20142cc..e68f82b 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -5,106 +5,299 @@ 'use strict'; const { app } = require('@azure/functions'); -const crypto = require('crypto'); -const { - dispatchOtp, - parseEnvelope, - decryptDeliveryContext, - contextToDispatch, - startProviderCredentialRefresh, - stopProviderCredentialRefresh, - MODE, -} = require('./dispatch'); +const crypto = require('node:crypto'); +const { performance } = require('node:perf_hooks'); const { readConfig } = require('./config'); -const { RequestLog } = require('./requestLog'); +const { parseEntraPayload } = require('./entraPayload'); +const { OtpDelivery } = require('./delivery'); +const { decryptDeliveryContext } = require('./jwe'); +const { selectProvider } = require('./providers'); +const { + credentialTokenService, + reportRefreshFailure, +} = require('./credentials'); +const { + ProviderTransportError, + isValidProviderUrl, + parseProviderTimeout, + sendProviderRequest, +} = require('./providerTransport'); +const { + safeIdentifier, + createRequestContext, + payloadContext, + providerContext, + credentialContext, + emit, + requestFailed, + requestCompleted, + unexpectedError, +} = require('./logging'); + +async function startProviderCredentialRefresh() { + const config = readConfig(); + if (!config.providerName) return; + const provider = selectProvider(config.providerName); + if (!provider || (config.providerAuthMode + && config.providerAuthMode !== provider.authenticationMode)) { + reportRefreshFailure('configuration'); + return; + } + try { + await credentialTokenService.getCredentials(provider.credentialSpec, config); + } catch { + if (!credentialTokenService.current) reportRefreshFailure('configuration'); + } +} + +function stopProviderCredentialRefresh() { + credentialTokenService.close(); +} app.hook.appStart(startProviderCredentialRefresh); app.hook.appTerminate(stopProviderCredentialRefresh); app.http('SendOtp', { methods: ['POST'], - authLevel: 'anonymous', // Protected by platform authentication in Azure. - handler: async (request, context) => { + authLevel: 'anonymous', + handler: async (request, azureContext) => { const requestId = crypto.randomUUID(); + const headerCorrelationId = request.headers.get('x-ms-correlation-id'); const msRequestId = request.headers.get('x-ms-client-request-id'); - const headerCorrelationId = request.headers.get('x-ms-correlation-id') || null; - const log = new RequestLog(context, requestId, msRequestId, headerCorrelationId); - let correlationId = headerCorrelationId || requestId; + let logContext = createRequestContext( + azureContext, + requestId, + msRequestId, + headerCorrelationId, + ); + let correlationId = safeIdentifier(headerCorrelationId) || requestId; let evaluation = false; - let httpStatus = 500; - const respond = (status, jsonBody) => { - httpStatus = status; - log.responsePrepared(status, Object.hasOwn(jsonBody, 'nonce'), Object.hasOwn(jsonBody, 'correlationId')); - return { status, jsonBody }; + let status = 500; + let failureEmitted = false; + + const fail = (failureStage, failureReason, httpStatus) => { + failureEmitted = true; + requestFailed(logContext, failureStage, failureReason, httpStatus); + return httpStatus; + }; + const respond = (httpStatus, jsonBody) => { + status = httpStatus; + emit(logContext, 'response_prepared', { + httpStatus, + responseContainsNonce: Object.hasOwn(jsonBody, 'nonce'), + responseContainsCorrelationId: Object.hasOwn(jsonBody, 'correlationId'), + }); + return { status: httpStatus, jsonBody }; }; try { - log.service('request_received'); + emit(logContext, 'request_received'); const config = readConfig(); - const clientRequestId = msRequestId || requestId; - let payload; + let rawPayload; try { - payload = JSON.parse(await request.text()); + rawPayload = JSON.parse(await request.text()); } catch { - log.failure('request_validation', 'invalid JSON body', 400); + fail('request_validation', 'invalid JSON body', 400); return respond(400, { error: 'bad_request', reason: 'invalid JSON body', requestId }); } - const parsed = parseEnvelope(payload); + const parsed = parseEntraPayload(rawPayload); if (parsed.error) { - log.failure('request_validation', parsed.error, 400); + fail('request_validation', parsed.error, 400); return respond(400, { error: 'bad_request', reason: parsed.error, requestId }); } - const envelope = parsed.envelope; - correlationId = envelope.correlationId || headerCorrelationId || requestId; - evaluation = envelope.mode === MODE.EVALUATION; - log.envelopeValidated(envelope, envelope.correlationId || headerCorrelationId, - envelope.correlationId ? 'envelope' : 'header'); + const payload = parsed.payload; + logContext = payloadContext(logContext, payload); + correlationId = typeof payload.correlationId === 'string' && payload.correlationId + ? payload.correlationId + : headerCorrelationId + || requestId; + evaluation = payload.isEvaluation; + emit(logContext, 'envelope_validated', { + envelopeType: payload.type, + ttlSeconds: payload.ttlSeconds ?? null, + encryptedDeliveryContextPresent: true, + }); - let delivery; - let header; + let decrypted; try { - ({ context: delivery, header } = await decryptDeliveryContext( - envelope.encryptedDeliveryContext, config)); + decrypted = await decryptDeliveryContext( + payload.encryptedDeliveryContext, + config.decryptionKeyPem, + ); } catch { - log.failure('decryption', 'decryption_failed', 400); + fail('decryption', 'decryption_failed', 400); return respond(400, { error: 'decryption_failed', correlationId, requestId }); } - log.service('delivery_context_decrypted'); + emit(logContext, 'delivery_context_decrypted'); - // Key ID is advisory after authenticated decryption. - if (config.expectedKeyId && config.expectedKeyId !== header.kid) { - log.keyIdMismatch(); + if (config.expectedKeyId && config.expectedKeyId !== decrypted.keyId) { + emit(logContext, 'encryption_key_id_mismatch', {}, 'warn'); + } + if (!decrypted.delivery?.isComplete) { + fail('delivery_context_validation', 'incomplete delivery context', 400); + return respond(400, { + error: 'bad_request', + reason: 'incomplete delivery context', + correlationId, + requestId, + }); } - if (!delivery?.isComplete) { - log.failure('delivery_context_validation', 'incomplete delivery context', 400); - return respond(400, { error: 'bad_request', reason: 'incomplete delivery context', correlationId, requestId }); + if (evaluation) { + emit(logContext, 'evaluation_completed'); + return respond(200, { + nonce: decrypted.delivery.nonce, + correlationId, + providerStatus: 'accepted', + }); + } + + const provider = selectProvider(config.providerName); + if (!provider) { + fail('provider_selection', 'unknown_provider', 400); + return respond(400, { error: 'provider_delivery_failed', correlationId, requestId }); } + logContext = providerContext(logContext, provider); + emit(logContext, 'provider_selected'); - // 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, log }).catch(() => { - if (!log.data.failureStage) log.failure('provider_dispatch', 'unexpected_error', 500); - return { httpStatus: 500 }; + const channel = payload.channelName; + if (config.providerChannel && config.providerChannel !== channel) { + fail('provider_configuration', 'channel_not_configured', 400); + return respond(400, { error: 'provider_delivery_failed', correlationId, requestId }); + } + if (config.providerAuthMode + && config.providerAuthMode !== provider.authenticationMode) { + fail('provider_configuration', 'authentication_mode_mismatch', 502); + return respond(502, { error: 'provider_delivery_failed', correlationId, requestId }); + } + if (!isValidProviderUrl(config.providerEndpoint)) { + fail('provider_configuration', 'invalid_provider_endpoint', 502); + return respond(502, { error: 'provider_delivery_failed', correlationId, requestId }); + } + + let credential; + const credentialStarted = performance.now(); + try { + if (provider.authenticationMode === 'oauth') { + logContext = credentialContext(logContext, config); + } + emit(logContext, 'provider_credential_resolution_started', { + providerCredentialSource: provider.authenticationMode === 'oauth' + ? 'managed_identity_client_assertion' : 'key_vault', + providerTenantId: logContext.providerTenantId, + functionOutboundClientId: logContext.functionOutboundClientId, + functionOutboundManagedIdentityClientId: + logContext.functionOutboundManagedIdentityClientId, }); - if (result.httpStatus !== 200) { - return respond(result.httpStatus, { error: 'provider_delivery_failed', correlationId, requestId }); + credential = await credentialTokenService.getCredentials( + provider.credentialSpec, + config, + ); + } catch { + fail('provider_credentials', 'credential_unavailable', 502); + return respond(502, { error: 'provider_delivery_failed', correlationId, requestId }); + } + const needsIdentity = provider.credentialSpec.identityKeyVaultSecretName; + const credentialUnavailable = !credential + || (credential.mode === 'apiKey' + && (!credential.secret || (needsIdentity && !credential.identity))) + || (credential.mode === 'oauth' && !credential.accessToken); + if (credentialUnavailable) { + fail('provider_credentials', 'credential_unavailable', 502); + return respond(502, { error: 'provider_delivery_failed', correlationId, requestId }); + } + emit(logContext, 'provider_credential_resolved', { + providerCredentialElapsedMs: Math.floor(performance.now() - credentialStarted), + }); + + const delivery = new OtpDelivery({ + phoneNumber: decrypted.delivery.phoneNumber, + message: decrypted.delivery.message, + channel, + messageId: msRequestId || requestId, + correlationId, + locale: decrypted.delivery.locale, + }); + let providerRequest; + try { + emit(logContext, 'provider_request_build_started'); + providerRequest = provider.createRequest({ + channel, + endpoint: config.providerEndpoint, + delivery, + credential, + env: config.env, + }); + } catch { + fail('provider_request_build', 'request_build_failed', 502); + return respond(502, { error: 'provider_delivery_failed', correlationId, requestId }); + } + + let transportResponse; + try { + transportResponse = await sendProviderRequest( + providerRequest, + parseProviderTimeout(config.providerTimeoutMs), + logContext, + ); + } catch (error) { + if (error instanceof ProviderTransportError) { + fail(error.stage, error.reason, error.httpStatus); + return respond(error.httpStatus, { + error: 'provider_delivery_failed', + correlationId, + requestId, + }); } - } else { - log.service('evaluation_completed'); + throw error; + } + + let result; + try { + result = provider.interpretResponse(transportResponse); + } catch { + fail('provider_response', 'response_parse_failed', 500); + return respond(500, { error: 'provider_delivery_failed', correlationId, requestId }); } + emit(logContext, 'provider_response_processed', { + providerHttpStatus: result.providerHttpStatus, + providerStatus: result.statusRecognized + ? result.providerStatusName || result.providerStatusCode + : 'unmapped', + providerOutcome: result.outcome, + providerMessageId: safeIdentifier(result.providerMessageId), + providerElapsedMs: transportResponse.elapsedMs, + failureReason: result.failureReason, + httpStatus: result.httpStatus, + }, result.httpStatus >= 500 ? 'error' : result.httpStatus === 200 ? 'log' : 'warn'); - // Only a successful delivery (or validated evaluation) may echo the nonce and accepted. - return respond(200, { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }); + if (result.httpStatus >= 400) { + fail('provider_response', result.failureReason || 'provider_rejected', result.httpStatus); + return respond(result.httpStatus, { + error: 'provider_delivery_failed', + correlationId, + requestId, + }); + } + return respond(200, { + nonce: decrypted.delivery.nonce, + correlationId, + providerStatus: 'accepted', + }); } catch { - if (!log.data.failureStage) log.failure('handler', 'unexpected_error', 500); + if (!failureEmitted) { + failureEmitted = true; + unexpectedError(logContext); + } return respond(500, { error: 'delivery_failed', correlationId, requestId }); } finally { - log.complete(httpStatus); + requestCompleted(logContext, status, status === 200 + ? evaluation ? 'evaluated' : 'accepted' + : 'failed'); } }, }); + +module.exports = { startProviderCredentialRefresh, stopProviderCredentialRefresh }; diff --git a/javascript/src/functions/credentials.js b/javascript/src/functions/credentials.js index 29b982c..530702a 100644 --- a/javascript/src/functions/credentials.js +++ b/javascript/src/functions/credentials.js @@ -161,7 +161,7 @@ class AccessTokenCache { } // Owns one selected cache and one periodic refresh; configuration changes require a worker restart. -class ProviderCredentials { +class CredentialTokenService { /** @param {{cacheOptions?: RefreshOptions, reportFailure?: (kind: string) => void}} [options] */ constructor({ cacheOptions = {}, reportFailure = reportRefreshFailure } = {}) { this.now = cacheOptions.now || Date.now; @@ -180,7 +180,7 @@ class ProviderCredentials { this.closed = false; } /** @param {AuthConfig} auth @param {AppConfig} config */ - async resolve(auth, config) { + async getCredentials(auth, config) { if (this.closed) throw unavailable(); if (!this.current) { try { @@ -200,6 +200,7 @@ class ProviderCredentials { if (!value) throw unavailable(); return value; } + resolve(auth, config) { return this.getCredentials(auth, config); } refresh() { if (this.closed || !this.current) return Promise.reject(unavailable()); if (this.pending) return this.pending; @@ -234,9 +235,19 @@ class ProviderCredentials { this.controller?.abort(); this.current?.stop(); } - [inspect.custom]() { return '[ProviderCredentials]'; } - toJSON() { return '[ProviderCredentials]'; } + [inspect.custom]() { return '[CredentialTokenService]'; } + toJSON() { return '[CredentialTokenService]'; } } -const providerCredentials = new ProviderCredentials(); -module.exports = { ApiKeyCache, AccessTokenCache, ProviderCredentials, providerCredentials, reportRefreshFailure }; +const credentialTokenService = new CredentialTokenService(); +const ProviderCredentials = CredentialTokenService; +const providerCredentials = credentialTokenService; +module.exports = { + ApiKeyCache, + AccessTokenCache, + CredentialTokenService, + credentialTokenService, + ProviderCredentials, + providerCredentials, + reportRefreshFailure, +}; diff --git a/javascript/src/functions/delivery.js b/javascript/src/functions/delivery.js new file mode 100644 index 0000000..20ee0b9 --- /dev/null +++ b/javascript/src/functions/delivery.js @@ -0,0 +1,43 @@ +'use strict'; + +const { inspect } = require('node:util'); + +class DeliveryContext { + constructor({ nonce, phoneNumber, message, extension, locale, riskContext }) { + this.nonce = nonce; + this.phoneNumber = phoneNumber; + this.message = message; + this.extension = extension; + this.locale = locale; + this.riskContext = riskContext; + Object.freeze(this); + } + + static fromPayload(value) { + return value && typeof value === 'object' && !Array.isArray(value) + ? new DeliveryContext(value) : null; + } + + get isComplete() { + return [this.nonce, this.phoneNumber, this.message] + .every((value) => typeof value === 'string' && value.trim().length > 0); + } + + [inspect.custom]() { return '[DeliveryContext]'; } +} + +class OtpDelivery { + constructor({ phoneNumber, message, channel, messageId, correlationId, locale }) { + this.phoneNumber = phoneNumber; + this.message = message; + this.channel = channel; + this.messageId = messageId; + this.correlationId = correlationId; + this.locale = locale; + Object.freeze(this); + } + + [inspect.custom]() { return '[OtpDelivery]'; } +} + +module.exports = { DeliveryContext, OtpDelivery }; diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js deleted file mode 100644 index 5e08ace..0000000 --- a/javascript/src/functions/dispatch.js +++ /dev/null @@ -1,426 +0,0 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// - -'use strict'; - -const crypto = require('crypto'); -const { compactDecrypt } = require('jose'); -const { readConfig } = require('./config'); -const { DeliveryContext, TextToVoice } = require('./models'); -const { providerCredentials, reportRefreshFailure } = require('./credentials'); - -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 }); - -function normalizeChannel(channel) { - 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' && Object.hasOwn(MODE_BY_NAME, mode.toLowerCase())) { - return MODE_BY_NAME[mode.toLowerCase()]; - } - return null; -} - -/** @returns {{envelope?: import('./models').Envelope, error?: string}} */ -function parseEnvelope(payload) { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - return { error: 'invalid envelope' }; - } - const { type, tenantId, correlationId, channel, mode, ttlSeconds, encryptedDeliveryContext } = payload; - 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' }; - } - const modeCode = normalizeMode(mode); - if (!modeCode) { - 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; - -function assertWellFormedJwe(compactJwe) { - if (typeof compactJwe !== 'string' || compactJwe.length === 0) { - throw new Error('malformed JWE'); - } - if (compactJwe.length > MAX_JWE_LENGTH) { - throw new Error('delivery context exceeds size limit'); - } - const segments = compactJwe.split('.'); - if (segments.length !== 5 || segments.some((segment) => segment.length === 0)) { - throw new Error('malformed JWE: expected five non-empty segments'); - } -} - -function readProtectedHeader(compactJwe) { - const protectedSegment = String(compactJwe).split('.')[0] || ''; - return JSON.parse(Buffer.from(protectedSegment, 'base64url').toString('utf8')); -} - -let cachedKey; -let cachedKeyPem; - -function normalizePem(value) { - const text = String(value || ''); - if (text.includes('-----BEGIN')) return text; - return Buffer.from(text, 'base64').toString('utf8'); -} - -function loadPrivateKey(pem) { - if (!pem) { - throw new Error('private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)'); - } - if (cachedKey && cachedKeyPem === pem) { - return cachedKey; - } - cachedKey = crypto.createPrivateKey(normalizePem(pem)); - cachedKeyPem = pem; - return cachedKey; -} - -async function decryptDeliveryContext(compactJwe, config = readConfig()) { - assertWellFormedJwe(compactJwe); - const header = readProtectedHeader(compactJwe); - const privateKey = loadPrivateKey(config.decryptionKeyPem); - // Pin alg/enc so a tampered header can't downgrade the crypto. - const { plaintext } = await compactDecrypt(compactJwe, privateKey, { - keyManagementAlgorithms: ['RSA-OAEP-256'], - contentEncryptionAlgorithms: ['A256GCM'], - }); - return { header, context: DeliveryContext.fromPayload(JSON.parse(Buffer.from(plaintext).toString('utf8'))) }; -} - -/** - * @param {DeliveryContext} context - * @param {import('./models').Envelope} envelope - * @param {string} messageId - * @returns {import('./models').DispatchRequest} - */ -function contextToDispatch(context, envelope, messageId) { - const channel = CHANNEL_BY_CODE[envelope.channel]; - return { - destination: context.phoneNumber, - message: context.message, - channel, - messageId, - correlationId: envelope.correlationId, - locale: context.locale || undefined, - textToVoice: context.textToVoice, - }; -} - -const OUTCOME = Object.freeze({ - CONTINUE: 'Continue', - FAIL: 'Fail', - BLOCK: 'Block', - STEP_UP: 'StepUp', -}); - -const providerRegistry = new Map( - [ - require('./providers/infobip'), - require('./providers/sinch'), - require('./providers/soprano'), - require('./providers/telesign'), - ].map((providerModule) => [ - providerModule.manifest.id.toLowerCase(), - { manifest: providerModule.manifest, adapter: providerModule }, - ]), -); - -function getProvider(providerId) { - return providerId ? providerRegistry.get(String(providerId).trim().toLowerCase()) || null : null; -} - -async function resolveProviderCredential(authConfiguration = {}, config) { - return providerCredentials.resolve(authConfiguration, config); -} - -async function startProviderCredentialRefresh() { - const config = readConfig(); - if (!config.providerName) return; - const provider = getProvider(config.providerName); - if (!provider || (config.providerAuthMode && config.providerAuthMode !== provider.manifest.auth.mode)) { - reportRefreshFailure('configuration'); - return; - } - try { - await resolveProviderCredential(provider.manifest.auth, config); - } catch { - // The cache reports acquisition failures; also report configurations rejected before caching. - if (!providerCredentials.current) reportRefreshFailure('configuration'); - } -} - -function stopProviderCredentialRefresh() { - providerCredentials.close(); -} - -// Status mappings may restrict HTTP success, but cannot turn failed HTTP into Continue. -/** @param {import('./models').ParsedResponse} parsedResponse */ -function resolveOutcome(manifest, parsedResponse) { - const responseMapping = manifest.responseMapping || {}; - const providerStatusKey = parsedResponse.providerStatusName || parsedResponse.providerStatusCode; - 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 200; - case OUTCOME.BLOCK: - return 403; - case OUTCOME.STEP_UP: - return 409; - case OUTCOME.FAIL: - if (providerHttpStatus === 429) return 429; - if (providerHttpStatus === 401 || providerHttpStatus === 403) return 401; - if (providerHttpStatus >= 400 && providerHttpStatus < 500) return 400; - return 502; - default: - 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, log) { - const abortController = new AbortController(); - let timedOut = false; - const timeoutTimer = setTimeout(() => { - timedOut = true; - abortController.abort(); - }, timeoutMilliseconds); - - try { - log?.providerRequestStarted(timeoutMilliseconds); - 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. - }); - log?.providerResponseReceived(response.status); - 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); - log?.providerRequestFinished(); - } -} - -const failBody = (providerId, channel, reason, dispatch, requestId) => - ({ status: 'failed', outcome: OUTCOME.FAIL, provider: providerId, channel, reason, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }); - -async function sendViaProvider(providerEntry, dispatch, options) { - const { requestId, config, log } = options; - const { manifest, adapter } = providerEntry; - const providerId = manifest.id; - const channel = dispatch.channel === undefined ? 'sms' - : (typeof dispatch.channel === 'string' ? dispatch.channel.toLowerCase() : null); - - if (!['sms', 'voice'].includes(channel)) { - log?.failure('provider_configuration', 'unsupported_channel', 400); - return { httpStatus: 400, body: { status: 'error', reason: 'unsupported channel', requestId } }; - } - if (config.providerChannel && config.providerChannel !== channel) { - log?.failure('provider_configuration', 'channel_not_configured', 400); - return { httpStatus: 400, body: { status: 'error', provider: providerId, reason: 'channel not configured', requestId } }; - } - if (config.providerAuthMode && config.providerAuthMode !== manifest.auth?.mode) { - log?.failure('provider_configuration', 'authentication_mode_mismatch', 502); - return { httpStatus: 502, body: failBody(providerId, channel, 'provider authentication mismatch', dispatch, requestId) }; - } - - if (channel === 'voice' && manifest.requiresTextToVoice - && (!(dispatch.textToVoice instanceof TextToVoice) || !dispatch.textToVoice.isComplete)) { - log?.failure('provider_request_build', 'incomplete_voice_context', 400); - return { httpStatus: 400, body: failBody(providerId, channel, 'incomplete voice context', dispatch, requestId) }; - } - - const endpointBaseUrl = config.providerEndpoint; - if (!isValidProviderUrl(endpointBaseUrl)) { - log?.failure('provider_configuration', 'invalid_provider_endpoint', 502); - return { httpStatus: 502, body: failBody(providerId, channel, 'provider endpoint missing or invalid', dispatch, requestId) }; - } - - let credential = null; - try { - log?.credentialResolutionStarted(config); - credential = await resolveProviderCredential(manifest.auth, config); - } catch { - log?.failure('provider_credentials', 'credential_unavailable', 502); - return { httpStatus: 502, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; - } - const identityRequired = credential?.mode === 'apiKey' && !!manifest.auth?.identityKeyVaultSecretName; - const credentialUnavailable = !credential - || (credential.mode === 'apiKey' && (!credential.secret || (identityRequired && !credential.identity))) - || (credential.mode === 'oauth' && !credential.accessToken); - if (credentialUnavailable) { - log?.failure('provider_credentials', 'credential_unavailable', 502); - return { httpStatus: 502, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; - } - log?.credentialResolved(); - - let providerRequest; - try { - log?.service('provider_request_build_started'); - providerRequest = adapter.buildRequest({ - channel, - endpoint: endpointBaseUrl, - dispatch, - credential, - env: config.env, - }); - } catch { - log?.failure('provider_request_build', 'request_build_failed', 502); - return { httpStatus: 502, body: failBody(providerId, channel, 'provider request failed', dispatch, requestId) }; - } - - if (!isValidProviderUrl(providerRequest.url)) { - log?.failure('provider_request_build', 'invalid_provider_request_url', 502); - return { httpStatus: 502, body: failBody(providerId, channel, 'provider request URL invalid', dispatch, requestId) }; - } - log?.providerRequestBuilt(providerRequest.method || 'POST', providerRequest.url); - - const timeoutMilliseconds = parseProviderTimeout(config.providerTimeoutMs); - let providerResponse; - let responseText; - try { - ({ response: providerResponse, responseText } = await fetchWithTimeout(providerRequest, timeoutMilliseconds, log)); - } catch (error) { - const isTimeout = error.name === 'TimeoutError'; - const httpStatus = isTimeout ? 504 : 502; - log?.failure('provider_transport', isTimeout ? 'provider_timeout' : 'provider_network_error', httpStatus); - return { httpStatus, body: failBody(providerId, channel, isTimeout ? 'provider request timed out' : 'provider request failed', dispatch, requestId) }; - } - - let responseJson; - let validJson = true; - try { - responseJson = JSON.parse(responseText); - } catch { - validJson = false; - log?.service('provider_response_invalid_json', {}, 'warn'); - responseJson = {}; - } - - let parsedResponse; - let outcome; - let httpStatus; - try { - parsedResponse = adapter.parseResponse({ - httpStatus: providerResponse.status, - ok: providerResponse.ok, - json: responseJson, - }); - outcome = resolveOutcome(manifest, parsedResponse); - httpStatus = outcomeToHttpStatus(outcome, parsedResponse.providerHttpStatus); - } catch (error) { - log?.failure('provider_response', 'response_parse_failed', 500); - throw error; - } - log?.providerResponseProcessed(manifest, parsedResponse, outcome, httpStatus, validJson); - - return { - httpStatus, - body: { - status: outcome === OUTCOME.CONTINUE ? 'accepted' : 'failed', - outcome, - provider: providerId, - channel, - messageId: dispatch.messageId, - correlationId: dispatch.correlationId, - requestId, - }, - }; -} - -async function dispatchOtp(dispatch, { config = readConfig(), requestId, log } = {}) { - const providerEntry = getProvider(config.providerName); - if (!providerEntry) { - log?.failure('provider_selection', 'unknown_provider', 400); - return { - httpStatus: 400, - body: { status: 'error', reason: 'unknown provider', requestId }, - }; - } - log?.providerSelected(providerEntry.manifest); - return sendViaProvider(providerEntry, dispatch, { config, requestId, log }); -} - -module.exports = { - parseEnvelope, - decryptDeliveryContext, - contextToDispatch, - MODE, - dispatchOtp, - getProvider, - resolveOutcome, - outcomeToHttpStatus, - parseProviderTimeout, - isValidProviderUrl, - resolveProviderCredential, - startProviderCredentialRefresh, - stopProviderCredentialRefresh, -}; diff --git a/javascript/src/functions/entraPayload.js b/javascript/src/functions/entraPayload.js new file mode 100644 index 0000000..b53c7d0 --- /dev/null +++ b/javascript/src/functions/entraPayload.js @@ -0,0 +1,64 @@ +'use strict'; + +const { inspect } = require('node:util'); + +const SUPPORTED_TYPE = 'microsoft.mfa.otpDeliver.v1'; +const CHANNEL = Object.freeze({ SMS: 1, VOICE: 2 }); +const MODE = Object.freeze({ LIVE: 1, EVALUATION: 2 }); +const CHANNEL_BY_NAME = Object.freeze({ sms: CHANNEL.SMS, voice: CHANNEL.VOICE }); +const MODE_BY_NAME = Object.freeze({ live: MODE.LIVE, evaluation: MODE.EVALUATION }); + +function normalizeEnum(value, names, first, second) { + if (value === first || value === second) return value; + if (typeof value === 'string') return names[value.toLowerCase()] || null; + return null; +} + +class EntraSendOtpPayload { + constructor({ type, tenantId, correlationId, channel, mode, ttlSeconds, encryptedDeliveryContext }) { + this.type = type; + this.tenantId = tenantId; + this.correlationId = correlationId; + this.channel = channel; + this.mode = mode; + this.ttlSeconds = ttlSeconds; + this.encryptedDeliveryContext = encryptedDeliveryContext; + Object.freeze(this); + } + + get channelName() { return this.channel === CHANNEL.VOICE ? 'voice' : 'sms'; } + get isEvaluation() { return this.mode === MODE.EVALUATION; } + [inspect.custom]() { return '[EntraSendOtpPayload]'; } +} + +function parseEntraPayload(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { error: 'invalid envelope' }; + } + if (value.type !== SUPPORTED_TYPE) { + return { error: 'unsupported envelope type' }; + } + if (typeof value.encryptedDeliveryContext !== 'string' + || !value.encryptedDeliveryContext.trim()) { + return { error: 'encryptedDeliveryContext is required' }; + } + const channel = normalizeEnum(value.channel, CHANNEL_BY_NAME, CHANNEL.SMS, CHANNEL.VOICE); + if (!channel) return { error: 'unsupported channel' }; + const mode = normalizeEnum(value.mode, MODE_BY_NAME, MODE.LIVE, MODE.EVALUATION); + if (!mode) return { error: 'unsupported mode' }; + if (Object.hasOwn(value, 'ttlSeconds')) { + if (!Number.isInteger(value.ttlSeconds) || value.ttlSeconds > 2147483647) { + return { error: 'invalid ttlSeconds' }; + } + if (value.ttlSeconds <= 0) return { error: 'ttlSeconds expired' }; + } + return { + payload: new EntraSendOtpPayload({ + ...value, + channel, + mode, + }), + }; +} + +module.exports = { EntraSendOtpPayload, parseEntraPayload, CHANNEL, MODE, SUPPORTED_TYPE }; diff --git a/javascript/src/functions/jwe.js b/javascript/src/functions/jwe.js new file mode 100644 index 0000000..cf9c19e --- /dev/null +++ b/javascript/src/functions/jwe.js @@ -0,0 +1,52 @@ +'use strict'; + +const crypto = require('node:crypto'); +const { compactDecrypt } = require('jose'); +const { DeliveryContext } = require('./delivery'); + +const MAX_JWE_LENGTH = 16384; +let cachedKey; +let cachedPem; + +function assertWellFormedJwe(value) { + if (typeof value !== 'string' || value.length === 0) throw new Error('malformed JWE'); + if (value.length > MAX_JWE_LENGTH) throw new Error('delivery context exceeds size limit'); + const segments = value.split('.'); + if (segments.length !== 5 || segments.some((segment) => segment.length === 0)) { + throw new Error('malformed JWE: expected five non-empty segments'); + } +} + +function normalizePem(value) { + const text = String(value || ''); + return text.includes('-----BEGIN') ? text : Buffer.from(text, 'base64').toString('utf8'); +} + +function loadPrivateKey(pem) { + if (!pem) throw new Error('private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)'); + if (cachedKey && cachedPem === pem) return cachedKey; + cachedKey = crypto.createPrivateKey(normalizePem(pem)); + cachedPem = pem; + return cachedKey; +} + +function readProtectedHeader(compactJwe) { + const protectedBytes = Buffer.from(compactJwe.split('.')[0], 'base64url'); + return JSON.parse(protectedBytes.toString('utf8')); +} + +async function decryptDeliveryContext(compactJwe, decryptionKeyPem) { + assertWellFormedJwe(compactJwe); + const protectedHeader = readProtectedHeader(compactJwe); + const { plaintext } = await compactDecrypt(compactJwe, loadPrivateKey(decryptionKeyPem), { + keyManagementAlgorithms: ['RSA-OAEP-256'], + contentEncryptionAlgorithms: ['A256GCM'], + }); + const payload = JSON.parse(Buffer.from(plaintext).toString('utf8')); + return Object.freeze({ + keyId: typeof protectedHeader.kid === 'string' ? protectedHeader.kid : null, + delivery: DeliveryContext.fromPayload(payload), + }); +} + +module.exports = { MAX_JWE_LENGTH, assertWellFormedJwe, decryptDeliveryContext }; diff --git a/javascript/src/functions/logging.js b/javascript/src/functions/logging.js new file mode 100644 index 0000000..2d17d78 --- /dev/null +++ b/javascript/src/functions/logging.js @@ -0,0 +1,147 @@ +'use strict'; + +const { performance } = require('node:perf_hooks'); + +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const HTTP_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']); +const CONTEXT_FIELDS = [ + 'functionName', 'functionRequestId', 'functionInvocationId', + 'x-ms-client-request-id', 'x-ms-correlation-id', 'msCorrelationIdSource', + 'omittedIdFields', 'channel', 'evaluation', 'providerName', 'providerAuthMode', + 'providerTenantId', 'functionOutboundClientId', 'functionOutboundManagedIdentityClientId', +]; + +function safeIdentifier(value) { + return typeof value === 'string' && IDENTIFIER_PATTERN.test(value) ? value : null; +} + +function createRequestContext(context, requestId, msRequestId, headerCorrelationId) { + const clientId = safeIdentifier(msRequestId); + const correlationId = safeIdentifier(headerCorrelationId); + const omittedIdFields = []; + if (msRequestId != null && !clientId && String(msRequestId).trim()) { + omittedIdFields.push('x-ms-client-request-id'); + } + if (headerCorrelationId != null && !correlationId && String(headerCorrelationId).trim()) { + omittedIdFields.push('x-ms-correlation-id'); + } + return Object.freeze({ + sink: context, + started: performance.now(), + functionName: 'SendOtp', + functionRequestId: requestId, + functionInvocationId: safeIdentifier(context.invocationId), + 'x-ms-client-request-id': clientId, + 'x-ms-correlation-id': correlationId, + msCorrelationIdSource: correlationId ? 'header' : 'none', + omittedIdFields: Object.freeze(omittedIdFields), + channel: null, + evaluation: null, + providerName: null, + providerAuthMode: null, + providerTenantId: null, + functionOutboundClientId: null, + functionOutboundManagedIdentityClientId: null, + }); +} + +function extendRequestContext(context, fields) { + return Object.freeze({ ...context, ...fields }); +} + +function payloadContext(context, payload) { + const hasPayloadCorrelationId = typeof payload.correlationId === 'string' + && payload.correlationId.length > 0; + const payloadCorrelationId = hasPayloadCorrelationId + ? safeIdentifier(payload.correlationId) : null; + const omittedIdFields = context.omittedIdFields + .filter((field) => field !== 'x-ms-correlation-id'); + if (hasPayloadCorrelationId && !payloadCorrelationId) { + omittedIdFields.push('x-ms-correlation-id'); + } + return extendRequestContext(context, { + 'x-ms-correlation-id': hasPayloadCorrelationId + ? payloadCorrelationId : context['x-ms-correlation-id'], + msCorrelationIdSource: hasPayloadCorrelationId + ? 'envelope' : context['x-ms-correlation-id'] ? 'header' : 'none', + omittedIdFields: Object.freeze(omittedIdFields), + channel: payload.channelName, + evaluation: payload.isEvaluation, + }); +} + +function providerContext(context, provider) { + return extendRequestContext(context, { + providerName: provider.name, + providerAuthMode: provider.authenticationMode, + }); +} + +function credentialContext(context, config) { + const fields = { + providerTenantId: config.providerTenantId, + functionOutboundClientId: config.outboundClientId, + functionOutboundManagedIdentityClientId: config.outboundManagedIdentityClientId, + }; + const omittedIdFields = [...context.omittedIdFields]; + const safeFields = {}; + for (const [name, value] of Object.entries(fields)) { + safeFields[name] = safeIdentifier(value); + if (value != null && !safeFields[name] && String(value).trim()) { + omittedIdFields.push(name); + } + } + return extendRequestContext(context, { + ...safeFields, + omittedIdFields: Object.freeze([...new Set(omittedIdFields)]), + }); +} + +function emit(context, eventName, details = {}, level = 'log') { + const base = Object.fromEntries(CONTEXT_FIELDS.map((field) => [field, context[field] ?? null])); + context.sink[level](JSON.stringify({ + logType: 'service', + eventName, + ...base, + ...details, + elapsedMs: Math.floor(performance.now() - context.started), + })); +} + +function requestFailed(context, failureStage, failureReason, httpStatus) { + emit(context, 'request_failed', { failureStage, failureReason, httpStatus }, + httpStatus >= 500 ? 'error' : 'warn'); +} + +function requestCompleted(context, httpStatus, result) { + emit(context, 'request_completed', { httpStatus, result }); +} + +function unexpectedError(context, httpStatus = 500) { + emit(context, 'unexpected_error', { httpStatus }, 'error'); +} + +function normalizeHttpMethod(method) { + const normalized = typeof method === 'string' ? method.toUpperCase() : ''; + return HTTP_METHODS.has(normalized) ? normalized : 'other'; +} + +function sanitizeEndpoint(value) { + const url = new URL(value); + return `${url.protocol}//${url.host}${url.pathname}`; +} + +module.exports = { + safeIdentifier, + createRequestContext, + extendRequestContext, + payloadContext, + providerContext, + credentialContext, + emit, + requestFailed, + requestCompleted, + unexpectedError, + normalizeHttpMethod, + sanitizeEndpoint, +}; diff --git a/javascript/src/functions/models.js b/javascript/src/functions/models.js deleted file mode 100644 index aedc920..0000000 --- a/javascript/src/functions/models.js +++ /dev/null @@ -1,96 +0,0 @@ -'use strict'; - -const { inspect } = require('node:util'); - -/** - * Validated routing metadata, constructed only after envelope validation. - * @typedef {Object} Envelope - * @property {string} type - * @property {*} tenantId - * @property {*} correlationId - * @property {number} channel - * @property {number} mode - * @property {number|undefined} ttlSeconds - * @property {string} encryptedDeliveryContext - */ - -/** - * Provider-neutral delivery request. Message text is never rewritten. - * @typedef {Object} DispatchRequest - * @property {string} destination - * @property {string} message - * @property {string} channel - * @property {string} messageId - * @property {*} correlationId - * @property {*} locale - */ - -class TextToVoice { - constructor({ beforePasswordText, password, language }) { - this.beforePasswordText = beforePasswordText; - this.password = password; - this.language = language; - } - - static fromPayload(payload) { - return payload && typeof payload === 'object' && !Array.isArray(payload) - ? new TextToVoice(payload) : null; - } - - get isComplete() { - return typeof this.beforePasswordText === 'string' - && [this.password, this.language].every(value => typeof value === 'string' && value.trim().length > 0); - } - - [inspect.custom]() { return '[TextToVoice]'; } -} - -class DeliveryContext { - constructor({ nonce, phoneNumber, message, extension, locale, riskContext, textToVoice = null }) { - this.nonce = nonce; - this.phoneNumber = phoneNumber; - this.message = message; - this.extension = extension; - this.locale = locale; - this.riskContext = riskContext; - this.textToVoice = TextToVoice.fromPayload(textToVoice); - } - - static fromPayload(payload) { - return payload && typeof payload === 'object' && !Array.isArray(payload) - ? new DeliveryContext(payload) : null; - } - - get isComplete() { - return [this.nonce, this.phoneNumber, this.message] - .every(value => typeof value === 'string' && value.trim().length > 0); - } - - [inspect.custom]() { return '[DeliveryContext]'; } -} - -// Adapter-normalized result for outcome mapping, not a public HTTP response. -class ParsedResponse { - /** - * @param {Object} fields - * @param {boolean} fields.success - * @param {number} fields.providerHttpStatus - * @param {string|null} [fields.providerMessageId] - * @param {string|null} [fields.providerStatusName] - * @param {string|null} [fields.providerStatusCode] - * @param {string|null} [fields.providerStatusDescription] - */ - constructor({ success, providerHttpStatus, providerMessageId = null, - providerStatusName = null, providerStatusCode = null, providerStatusDescription = null }) { - this.success = success; - this.providerHttpStatus = providerHttpStatus; - this.providerMessageId = providerMessageId; - this.providerStatusName = providerStatusName; - this.providerStatusCode = providerStatusCode; - this.providerStatusDescription = providerStatusDescription; - } - - [inspect.custom]() { return '[ParsedResponse]'; } -} - -module.exports = { DeliveryContext, TextToVoice, ParsedResponse }; diff --git a/javascript/src/functions/providerResult.js b/javascript/src/functions/providerResult.js new file mode 100644 index 0000000..b97ab75 --- /dev/null +++ b/javascript/src/functions/providerResult.js @@ -0,0 +1,40 @@ +'use strict'; + +const OUTCOME = Object.freeze({ CONTINUE: 'Continue', FAIL: 'Fail', BLOCK: 'Block' }); + +function endpointHttpStatus(outcome, providerHttpStatus) { + if (outcome === OUTCOME.CONTINUE) return 200; + if (outcome === OUTCOME.BLOCK) return 403; + if (providerHttpStatus === 429) return 429; + if (providerHttpStatus === 401 || providerHttpStatus === 403) return 401; + if (providerHttpStatus >= 400 && providerHttpStatus < 500) return 400; + return 502; +} + +function classifyFailure({ providerHttpStatus, outcome, statusRecognized, validJson = true }) { + if (!validJson) return 'invalid_provider_json'; + if (providerHttpStatus < 200 || providerHttpStatus >= 300) return 'provider_http_error'; + if (outcome !== OUTCOME.FAIL) return null; + return statusRecognized ? 'provider_rejected' : 'unrecognized_provider_status'; +} + +class ProviderResult { + constructor({ outcome, statusRecognized, providerHttpStatus, providerMessageId = null, + providerStatusName = null, providerStatusCode = null, providerStatusDescription = null, + failureReason = null, httpStatus = endpointHttpStatus(outcome, providerHttpStatus) }) { + this.outcome = outcome; + this.statusRecognized = statusRecognized; + this.providerHttpStatus = providerHttpStatus; + this.providerMessageId = providerMessageId; + this.providerStatusName = providerStatusName; + this.providerStatusCode = providerStatusCode; + this.providerStatusDescription = providerStatusDescription; + this.failureReason = failureReason; + this.httpStatus = httpStatus; + Object.freeze(this); + } + + toString() { return 'ProviderResult'; } +} + +module.exports = { OUTCOME, ProviderResult, endpointHttpStatus, classifyFailure }; diff --git a/javascript/src/functions/providerTransport.js b/javascript/src/functions/providerTransport.js new file mode 100644 index 0000000..cbbb9c4 --- /dev/null +++ b/javascript/src/functions/providerTransport.js @@ -0,0 +1,111 @@ +'use strict'; + +const { performance } = require('node:perf_hooks'); +const { emit, normalizeHttpMethod, sanitizeEndpoint } = require('./logging'); + +class ProviderTransportError extends Error { + constructor(httpStatus, stage, reason) { + super(reason); + this.httpStatus = httpStatus; + this.stage = stage; + this.reason = reason; + } +} + +function parseProviderTimeout(value) { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text) return 1500; + let milliseconds = 0; + for (const digit of text) { + if (digit < '0' || digit > '9') return 1500; + milliseconds = Math.min(2500, milliseconds * 10 + digit.charCodeAt(0) - 48); + } + return milliseconds > 0 ? milliseconds : 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]; + 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 sendProviderRequest(providerRequest, timeoutMs, logContext) { + if (!isValidProviderUrl(providerRequest?.url)) { + throw new ProviderTransportError(502, 'provider_request_build', 'invalid_provider_request_url'); + } + const providerHttpMethod = normalizeHttpMethod(providerRequest.method || 'POST'); + const providerEndpoint = sanitizeEndpoint(providerRequest.url); + emit(logContext, 'provider_request_built', { + providerHttpMethod, + providerEndpoint, + providerScheme: 'https', + redirectsAllowed: false, + }); + + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + const started = performance.now(); + try { + emit(logContext, 'provider_request_started', { + providerTimeoutMs: timeoutMs, + providerHttpMethod, + providerEndpoint, + }); + const response = await fetch(providerRequest.url, { + method: providerRequest.method || 'POST', + headers: providerRequest.headers, + body: providerRequest.body, + signal: controller.signal, + redirect: 'manual', + }); + emit(logContext, 'provider_response_received', { providerHttpStatus: response.status }); + const responseText = await response.text(); + try { + return Object.freeze({ + providerHttpStatus: response.status, + ok: response.ok, + json: JSON.parse(responseText), + validJson: true, + elapsedMs: Math.floor(performance.now() - started), + }); + } catch { + emit(logContext, 'provider_response_invalid_json', {}, 'warn'); + return Object.freeze({ + providerHttpStatus: response.status, + ok: response.ok, + json: null, + validJson: false, + elapsedMs: Math.floor(performance.now() - started), + }); + } + } catch { + throw new ProviderTransportError( + timedOut ? 504 : 502, + 'provider_transport', + timedOut ? 'provider_timeout' : 'provider_network_error', + ); + } finally { + clearTimeout(timer); + } +} + +module.exports = { + ProviderTransportError, + parseProviderTimeout, + isValidProviderUrl, + sendProviderRequest, +}; diff --git a/javascript/src/functions/providers/index.js b/javascript/src/functions/providers/index.js new file mode 100644 index 0000000..298fa2f --- /dev/null +++ b/javascript/src/functions/providers/index.js @@ -0,0 +1,18 @@ +'use strict'; + +const infobip = require('./infobip'); +const sinch = require('./sinch'); +const soprano = require('./soprano'); +const telesign = require('./telesign'); + +function selectProvider(name) { + switch (String(name || '').trim().toLowerCase()) { + case 'infobip': return infobip; + case 'sinch': return sinch; + case 'soprano': return soprano; + case 'telesign': return telesign; + default: return null; + } +} + +module.exports = { selectProvider, infobip, sinch, soprano, telesign }; diff --git a/javascript/src/functions/providers/infobip.js b/javascript/src/functions/providers/infobip.js index b0b1010..b92b98e 100644 --- a/javascript/src/functions/providers/infobip.js +++ b/javascript/src/functions/providers/infobip.js @@ -1,68 +1,79 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// - 'use strict'; -const { ParsedResponse } = require('../models'); - -// Voice integration is unverified; confirm the request format before production use. +const { OUTCOME, ProviderResult, classifyFailure } = require('../providerResult'); -const manifest = { - id: 'infobip', - auth: { mode: 'apiKey', keyVaultSecretName: 'infobip-api-key' }, - responseMapping: { - ACCEPTED: 'Continue', - PENDING: 'Continue', - DELIVERED: 'Continue', - REJECTED: 'Fail', - EXPIRED: 'Fail', - UNDELIVERABLE: 'Fail', - default: 'Fail', - }, -}; +const SUCCESS = new Set(['ACCEPTED', 'PENDING', 'DELIVERED']); +const REJECTED = new Set(['REJECTED', 'EXPIRED', 'UNDELIVERABLE']); -function buildRequest({ channel, endpoint, dispatch, credential, env }) { - const base = endpoint; - const senderId = env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify'; - const headers = { - Authorization: `App ${credential.secret}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }; +const provider = Object.freeze({ + name: 'infobip', + authenticationMode: 'apiKey', + credentialSpec: Object.freeze({ mode: 'apiKey', keyVaultSecretName: 'infobip-api-key' }), - if (channel === 'voice') { - const body = { - messages: [{ - from: senderId, - destinations: [{ to: dispatch.destination, messageId: dispatch.correlationId || dispatch.messageId }], - text: dispatch.message, - language: dispatch.locale || 'en', - voice: { name: 'Joanna', gender: 'female' }, - }], + createRequest({ channel, endpoint, delivery, credential, env }) { + const senderId = env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify'; + const messageId = delivery.correlationId || delivery.messageId; + const headers = { + Authorization: `App ${credential.secret}`, + 'Content-Type': 'application/json', + Accept: 'application/json', }; - return { url: `${base}/tts/3/advanced`, method: 'POST', headers, body: JSON.stringify(body) }; - } - - const body = { - messages: [{ - sender: senderId, - destinations: [{ to: dispatch.destination, messageId: dispatch.correlationId || dispatch.messageId }], - content: { text: dispatch.message }, - }], - }; - return { url: `${base}/sms/3/messages`, method: 'POST', headers, body: JSON.stringify(body) }; -} + if (channel === 'voice') { + return { + url: `${endpoint}/tts/3/advanced`, + method: 'POST', + headers, + body: JSON.stringify({ + messages: [{ + from: senderId, + destinations: [{ to: delivery.phoneNumber, messageId }], + text: delivery.message, + language: delivery.locale || 'en', + voice: { name: 'Joanna', gender: 'female' }, + }], + }), + }; + } + return { + url: `${endpoint}/sms/3/messages`, + method: 'POST', + headers, + body: JSON.stringify({ + messages: [{ + sender: senderId, + destinations: [{ to: delivery.phoneNumber, messageId }], + content: { text: delivery.message }, + }], + }), + }; + }, -function parseResponse({ httpStatus, ok, json }) { - const firstMessage = json && json.messages && json.messages[0]; - const status = (firstMessage && firstMessage.status) || {}; - return new ParsedResponse({ - success: ok, - providerHttpStatus: httpStatus, - providerMessageId: (firstMessage && firstMessage.messageId) || null, - providerStatusName: (status.groupName || status.name || '').toUpperCase() || null, - }); -} + interpretResponse(response) { + const message = Array.isArray(response.json?.messages) ? response.json.messages[0] : null; + const statusObject = message && typeof message.status === 'object' && !Array.isArray(message.status) + ? message.status : null; + const rawStatus = typeof statusObject?.groupName === 'string' + ? statusObject.groupName : typeof statusObject?.name === 'string' ? statusObject.name : null; + const status = rawStatus?.toUpperCase() || null; + const recognized = SUCCESS.has(status) || REJECTED.has(status); + const mappedOutcome = SUCCESS.has(status) ? OUTCOME.CONTINUE : OUTCOME.FAIL; + const outcome = response.ok ? mappedOutcome : OUTCOME.FAIL; + return new ProviderResult({ + outcome, + statusRecognized: recognized, + providerHttpStatus: response.providerHttpStatus, + providerMessageId: typeof message?.messageId === 'string' ? message.messageId : null, + providerStatusName: status, + providerStatusDescription: typeof statusObject?.description === 'string' + ? statusObject.description : null, + failureReason: classifyFailure({ + providerHttpStatus: response.providerHttpStatus, + outcome, + statusRecognized: recognized, + validJson: response.validJson, + }), + }); + }, +}); -module.exports = { manifest, buildRequest, parseResponse }; +module.exports = provider; diff --git a/javascript/src/functions/providers/sinch.js b/javascript/src/functions/providers/sinch.js index d519d12..1522d98 100644 --- a/javascript/src/functions/providers/sinch.js +++ b/javascript/src/functions/providers/sinch.js @@ -1,67 +1,83 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// - 'use strict'; -const { ParsedResponse } = require('../models'); +const { OUTCOME, ProviderResult, classifyFailure } = require('../providerResult'); -// A batch identifier indicates acceptance, not final delivery; delivery status arrives by callback. +const SUCCESS = new Set(['Dispatched', 'Delivered', 'Queued']); +const REJECTED = new Set(['Failed', 'Rejected']); -const manifest = { - id: 'sinch', - auth: { mode: 'apiKey', keyVaultSecretName: 'sinch-api-token' }, - responseMapping: { - Dispatched: 'Continue', - Delivered: 'Continue', - Queued: 'Continue', - Failed: 'Fail', - Rejected: 'Fail', - default: 'Fail', - }, -}; +function nonblankString(value) { + return typeof value === 'string' && value.trim() ? value : null; +} -function buildRequest({ channel, endpoint, dispatch, credential, env }) { - const headers = { - Authorization: `Bearer ${credential.secret}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }; +const provider = Object.freeze({ + name: 'sinch', + authenticationMode: 'apiKey', + credentialSpec: Object.freeze({ mode: 'apiKey', keyVaultSecretName: 'sinch-api-token' }), - if (channel === 'voice') { - // 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', - ttsCallout: { - destination: { type: 'number', endpoint: dispatch.destination }, - text: dispatch.message, - locale: dispatch.locale || 'en-US', - custom: dispatch.correlationId || dispatch.messageId, - }, + createRequest({ channel, endpoint, delivery, credential, env }) { + const headers = { + Authorization: ['Bearer', credential.secret].join(' '), + 'Content-Type': 'application/json', + Accept: 'application/json', }; - return { url: `${voiceBase}/calling/v1/callouts`, method: 'POST', headers, body: JSON.stringify(body) }; - } - - const smsBase = endpoint; - const servicePlanId = env.SINCH_SERVICE_PLAN_ID || ''; - const body = { - from: env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify', - to: [dispatch.destination], - body: dispatch.message, - client_reference: dispatch.correlationId || dispatch.messageId, - }; - return { url: `${smsBase}/xms/v1/${servicePlanId}/batches`, method: 'POST', headers, body: JSON.stringify(body) }; -} + if (channel === 'voice') { + const voiceBase = env.SINCH_VOICE_ENDPOINT || 'https://calling.api.sinch.com'; + return { + url: `${voiceBase}/calling/v1/callouts`, + method: 'POST', + headers, + body: JSON.stringify({ + method: 'ttsCallout', + ttsCallout: { + destination: { type: 'number', endpoint: delivery.phoneNumber }, + text: delivery.message, + locale: delivery.locale || 'en-US', + custom: delivery.correlationId || delivery.messageId, + }, + }), + }; + } + const servicePlanId = env.SINCH_SERVICE_PLAN_ID || ''; + return { + url: `${endpoint}/xms/v1/${servicePlanId}/batches`, + method: 'POST', + headers, + body: JSON.stringify({ + from: env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify', + to: [delivery.phoneNumber], + body: delivery.message, + client_reference: delivery.correlationId || delivery.messageId, + }), + }; + }, -function parseResponse({ httpStatus, ok, json }) { - const messageOrCallId = (json && (json.id || json.callId || json._links && json._links.self)) || null; - return new ParsedResponse({ - success: ok, - providerHttpStatus: httpStatus, - providerMessageId: typeof messageOrCallId === 'string' ? messageOrCallId : (messageOrCallId && messageOrCallId.href) || null, - providerStatusName: ok ? 'Dispatched' : (json && (json.text || json.status)) || null, - }); -} + interpretResponse(response) { + const payload = response.json && typeof response.json === 'object' && !Array.isArray(response.json) + ? response.json : {}; + const messageId = nonblankString(payload.id) || nonblankString(payload.callId); + let status = nonblankString(payload.status); + if (payload.status == null && response.ok && messageId) status = 'Dispatched'; + const recognized = SUCCESS.has(status) || REJECTED.has(status); + const mappedOutcome = SUCCESS.has(status) ? OUTCOME.CONTINUE : OUTCOME.FAIL; + const outcome = response.ok && messageId ? mappedOutcome : OUTCOME.FAIL; + const failureReason = response.validJson && response.ok && !messageId + ? 'missing_provider_message_id' + : classifyFailure({ + providerHttpStatus: response.providerHttpStatus, + outcome, + statusRecognized: recognized, + validJson: response.validJson, + }); + return new ProviderResult({ + outcome, + statusRecognized: recognized, + providerHttpStatus: response.providerHttpStatus, + providerMessageId: messageId, + providerStatusName: status, + providerStatusDescription: typeof payload.text === 'string' ? payload.text : null, + failureReason, + }); + }, +}); -module.exports = { manifest, buildRequest, parseResponse }; +module.exports = provider; diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index a1ca4d6..67e6043 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -1,83 +1,85 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// - 'use strict'; -const { ParsedResponse } = require('../models'); +const { OUTCOME, ProviderResult, classifyFailure } = require('../providerResult'); const DEFAULT_VOICE_LANGUAGE = 'en-US'; -const VOICE_GENDER = 1; -const VOICE_LOOP = 2; - -const manifest = { - id: 'soprano', - auth: { mode: 'oauth' }, - responseMapping: { - ENROUTE: 'Continue', - ACCEPTED: 'Continue', - SUBMITTED: 'Continue', - SENT: 'Continue', - DELIVERED: 'Continue', - QUEUED: 'Continue', - FAILED: 'Fail', - REJECTED: 'Fail', - FILTERED: 'Fail', - BLOCKED: 'Block', - default: 'Fail', - }, -}; +const SUCCESS = new Set(['ENROUTE', 'ACCEPTED', 'SUBMITTED', 'SENT', 'DELIVERED', 'QUEUED']); +const REJECTED = new Set(['FAILED', 'REJECTED', 'FILTERED']); function buildTextToVoice(message, locale) { const renderedMessage = String(message || ''); - const passcodeMatch = renderedMessage.match(/\d{6}/); - if (!passcodeMatch) { - throw new Error('voice message does not contain a six-digit passcode'); - } - - const passcodeIndex = passcodeMatch.index; + const match = renderedMessage.match(/[0-9]{6}/); + if (!match) throw new Error('voice message does not contain a six-digit passcode'); return { - beforePasswordText: renderedMessage.slice(0, passcodeIndex), - password: passcodeMatch[0], - afterPasswordText: renderedMessage.slice(passcodeIndex + passcodeMatch[0].length), + beforePasswordText: renderedMessage.slice(0, match.index), + password: match[0], + afterPasswordText: renderedMessage.slice(match.index + match[0].length), language: typeof locale === 'string' && locale.trim() ? locale : DEFAULT_VOICE_LANGUAGE, - gender: VOICE_GENDER, - loop: VOICE_LOOP, + gender: 1, + loop: 2, }; } -function buildRequest({ channel, endpoint, dispatch, credential }) { - const headers = { - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: `Bearer ${credential.accessToken}`, - }; - let destination = String(dispatch.destination || ''); - while (destination.startsWith('+')) destination = destination.slice(1); - const body = { - destination, - messageTypes: [channel === 'voice' ? 'voice' : 'sms'], - correlationId: dispatch.correlationId || dispatch.messageId, - shutterMode: false, - }; - if (channel === 'voice') { - body.voice = { text2voice: buildTextToVoice(dispatch.message, dispatch.locale) }; - } else { - body.text = dispatch.message; - } - return { url: endpoint, method: 'POST', headers, body: JSON.stringify(body) }; +function responseIdentifier(value) { + if (typeof value === 'string') return value || null; + return typeof value === 'number' && Number.isFinite(value) ? String(value) : null; } -function parseResponse({ httpStatus, ok, json }) { - const payload = (Array.isArray(json) ? json[0] : json) || {}; - const value = payload.status ?? payload.state; - const status = typeof value === 'string' && value ? value.toUpperCase() : 'UNKNOWN'; - return new ParsedResponse({ - success: ok, - providerHttpStatus: httpStatus, - providerMessageId: (payload.id != null ? String(payload.id) : null) || payload.messageId || null, - providerStatusName: status, - }); -} +const provider = Object.freeze({ + name: 'soprano', + authenticationMode: 'oauth', + credentialSpec: Object.freeze({ mode: 'oauth' }), + + createRequest({ channel, endpoint, delivery, credential }) { + const body = { + destination: String(delivery.phoneNumber || '').replace(/^\++/, ''), + messageTypes: [channel === 'voice' ? 'voice' : 'sms'], + correlationId: delivery.correlationId || delivery.messageId, + shutterMode: false, + }; + if (channel === 'voice') { + body.voice = { text2voice: buildTextToVoice(delivery.message, delivery.locale) }; + } else { + body.text = delivery.message; + } + return { + url: endpoint, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: ['Bearer', credential.accessToken].join(' '), + }, + body: JSON.stringify(body), + }; + }, + + interpretResponse(response) { + const root = Array.isArray(response.json) ? response.json[0] : response.json; + const payload = root && typeof root === 'object' && !Array.isArray(root) ? root : {}; + const selectedStatus = payload.status == null ? payload.state : payload.status; + const status = typeof selectedStatus === 'string' && selectedStatus.trim() + ? selectedStatus.toUpperCase() : 'UNKNOWN'; + const recognized = SUCCESS.has(status) || REJECTED.has(status) || status === 'BLOCKED'; + let mappedOutcome = OUTCOME.FAIL; + if (SUCCESS.has(status)) mappedOutcome = OUTCOME.CONTINUE; + if (status === 'BLOCKED') mappedOutcome = OUTCOME.BLOCK; + const outcome = !response.ok && mappedOutcome === OUTCOME.CONTINUE + ? OUTCOME.FAIL : mappedOutcome; + return new ProviderResult({ + outcome, + statusRecognized: recognized, + providerHttpStatus: response.providerHttpStatus, + providerMessageId: responseIdentifier(payload.id) || responseIdentifier(payload.messageId), + providerStatusName: status, + failureReason: classifyFailure({ + providerHttpStatus: response.providerHttpStatus, + outcome, + statusRecognized: recognized, + validJson: response.validJson, + }), + }); + }, +}); -module.exports = { manifest, buildRequest, parseResponse }; +module.exports = provider; diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index 129c9cc..3ae5d80 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -1,84 +1,81 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// - 'use strict'; -const { ParsedResponse } = require('../models'); +const { OUTCOME, ProviderResult, classifyFailure } = require('../providerResult'); const VOICE_PASSCODE_PATTERN = /(? [...passcode].join(', ')); + return `${paced} ${paced}`; +} -const manifest = { - id: 'telesign', - auth: { +const provider = Object.freeze({ + name: 'telesign', + authenticationMode: 'apiKey', + credentialSpec: Object.freeze({ mode: 'apiKey', keyVaultSecretName: 'telesign-api-key', identityKeyVaultSecretName: 'telesign-customer-id', - }, - // SMS: 200/203 delivered, 290/291/292 in progress. Voice: 100 answered, 101/102/103 placed/ringing/in progress. - responseMapping: { - 200: 'Continue', - 203: 'Continue', - 290: 'Continue', - 291: 'Continue', - 292: 'Continue', - 100: 'Continue', - 101: 'Continue', - 102: 'Continue', - 103: 'Continue', - 3001: 'Continue', - default: 'Fail', - }, -}; + }), -function buildVoiceMessage(message) { - const pacedMessage = message.replace( - VOICE_PASSCODE_PATTERN, - (passcode) => [...passcode].join(VOICE_DIGIT_SEPARATOR), - ); - return Array(VOICE_REPEAT_COUNT).fill(pacedMessage).join(VOICE_REPEAT_SEPARATOR); -} - -function buildRequest({ channel, endpoint, dispatch, credential }) { - if (!['sms', 'voice'].includes(channel)) throw new Error('unsupported channel'); - if (typeof dispatch.destination !== 'string' || !/^\+[1-9][0-9]{1,14}$/.test(dispatch.destination) - || dispatch.destination.trim() !== dispatch.destination) { - throw new Error('invalid recipient'); - } - const authorization = `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`; - const correlationId = typeof dispatch.correlationId === 'string' && dispatch.correlationId - ? dispatch.correlationId : dispatch.messageId; - const message = { text: channel === 'voice' ? buildVoiceMessage(dispatch.message) : dispatch.message }; - if (typeof dispatch.locale === 'string' && dispatch.locale.trim()) message.language = dispatch.locale; - return { - url: endpoint, - method: 'POST', - headers: { - Authorization: authorization, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - recipient: { phone_number: dispatch.destination }, - message, - channels: [{ channel }], - correlation_id: correlationId, - }), - }; -} + createRequest({ channel, endpoint, delivery, credential }) { + if (!['sms', 'voice'].includes(channel)) throw new Error('unsupported channel'); + if (typeof delivery.phoneNumber !== 'string' + || !/^\+[1-9][0-9]{1,14}$/.test(delivery.phoneNumber)) { + throw new Error('invalid recipient'); + } + const message = { + text: channel === 'voice' ? buildVoiceMessage(delivery.message) : delivery.message, + }; + if (typeof delivery.locale === 'string' && delivery.locale.trim()) { + message.language = delivery.locale; + } + const correlationId = typeof delivery.correlationId === 'string' && delivery.correlationId + ? delivery.correlationId : delivery.messageId; + return { + url: endpoint, + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + recipient: { phone_number: delivery.phoneNumber }, + message, + channels: [{ channel }], + correlation_id: correlationId, + }), + }; + }, -function parseResponse({ httpStatus, ok, json }) { - const status = (json && json.status) || {}; - return new ParsedResponse({ - success: ok, - providerHttpStatus: httpStatus, - providerMessageId: typeof json?.reference_id === 'string' ? json.reference_id : null, - providerStatusCode: Number.isInteger(status.code) ? String(status.code) : 'UNKNOWN', - providerStatusDescription: typeof status.description === 'string' ? status.description : null, - }); -} + interpretResponse(response) { + const payload = response.json && typeof response.json === 'object' && !Array.isArray(response.json) + ? response.json : {}; + const statusObject = payload.status && typeof payload.status === 'object' && !Array.isArray(payload.status) + ? payload.status : {}; + const code = typeof statusObject.code === 'number' && Number.isInteger(statusObject.code) + ? String(statusObject.code) : 'UNKNOWN'; + const recognized = SUCCESS.has(code); + const mappedOutcome = recognized ? OUTCOME.CONTINUE : OUTCOME.FAIL; + const outcome = response.ok ? mappedOutcome : OUTCOME.FAIL; + return new ProviderResult({ + outcome, + statusRecognized: recognized, + providerHttpStatus: response.providerHttpStatus, + providerMessageId: typeof payload.reference_id === 'string' ? payload.reference_id : null, + providerStatusCode: code, + providerStatusDescription: typeof statusObject.description === 'string' + ? statusObject.description : null, + failureReason: classifyFailure({ + providerHttpStatus: response.providerHttpStatus, + outcome, + statusRecognized: recognized, + validJson: response.validJson, + }), + }); + }, +}); -module.exports = { manifest, buildRequest, parseResponse }; +module.exports = provider; diff --git a/javascript/src/functions/requestLog.js b/javascript/src/functions/requestLog.js deleted file mode 100644 index 02e2499..0000000 --- a/javascript/src/functions/requestLog.js +++ /dev/null @@ -1,234 +0,0 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// - -'use strict'; - -const { performance } = require('node:perf_hooks'); - -const contextFields = [ - 'functionName', 'functionRequestId', 'functionInvocationId', - 'x-ms-client-request-id', 'x-ms-correlation-id', 'msCorrelationIdSource', 'omittedIdFields', - 'channel', 'evaluation', 'providerName', -]; -const credentialFields = [ - 'providerAuthMode', 'providerCredentialSource', 'providerTenantId', - 'functionOutboundClientId', 'functionOutboundManagedIdentityClientId', -]; -const httpMethods = new Set(['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']); - -const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; - -// Only explicitly selected metadata enters logs; never serialize request/provider models. -class RequestLog { - constructor(context, requestId, msRequestId, msCorrelationId) { - this.context = context; - this.started = performance.now(); - this.providerStarted = null; - this.credentialStarted = null; - this.data = { - functionName: 'SendOtp', - functionRequestId: requestId, - functionInvocationId: context.invocationId || null, - 'x-ms-client-request-id': null, - 'x-ms-correlation-id': null, - msCorrelationIdSource: 'none', - omittedIdFields: [], - envelopeType: null, - ttlSeconds: null, - channel: null, - evaluation: null, - encryptionKeyIdMismatch: false, - providerName: null, - providerAuthMode: null, - providerCredentialSource: null, - providerCredentialElapsedMs: null, - providerTenantId: null, - functionOutboundClientId: null, - functionOutboundManagedIdentityClientId: null, - providerHttpMethod: null, - providerEndpoint: null, - providerAttempted: false, - providerHttpStatus: null, - providerStatus: null, - providerOutcome: null, - providerMessageId: null, - providerElapsedMs: null, - providerTimeoutMs: null, - failureStage: null, - failureReason: null, - responseContainsNonce: null, - responseContainsCorrelationId: null, - }; - this.setIdentifier('x-ms-client-request-id', msRequestId); - this.setIdentifier('x-ms-correlation-id', msCorrelationId); - this.data.msCorrelationIdSource = this.data['x-ms-correlation-id'] ? 'header' : 'none'; - } - - setIdentifier(field, value) { - const valid = typeof value === 'string' && value.length <= 128 && identifierPattern.exec(value)?.[0] === value; - this.data[field] = valid ? value : null; - this.data.omittedIdFields = this.data.omittedIdFields.filter((name) => name !== field); - if (!valid && value != null && !(typeof value === 'string' && !value.trim())) { - this.data.omittedIdFields.push(field); - } - } - - service(eventName, details = {}, level = 'log') { - const context = Object.fromEntries(contextFields.map((key) => [key, this.data[key]])); - this.context[level](JSON.stringify({ - logType: 'service', eventName, ...context, ...details, - elapsedMs: Math.floor(performance.now() - this.started), - })); - } - - envelopeValidated(envelope, correlationId, source) { - this.data.envelopeType = envelope.type; - this.data.ttlSeconds = envelope.ttlSeconds ?? null; - this.data.channel = envelope.channel === 1 ? 'sms' : 'voice'; - this.data.evaluation = envelope.mode === 2; - this.setIdentifier('x-ms-correlation-id', correlationId); - this.data.msCorrelationIdSource = this.data['x-ms-correlation-id'] ? source : 'none'; - this.service('envelope_validated', { - envelopeType: this.data.envelopeType, - ttlSeconds: this.data.ttlSeconds, - encryptedDeliveryContextPresent: true, - }); - } - - keyIdMismatch() { - this.data.encryptionKeyIdMismatch = true; - this.service('encryption_key_id_mismatch', {}, 'warn'); - } - - providerSelected(manifest) { - this.data.providerName = manifest.id; - this.data.providerAuthMode = ['apiKey', 'oauth'].includes(manifest.auth?.mode) - ? manifest.auth.mode : 'unsupported'; - this.service('provider_selected', { providerAuthMode: this.data.providerAuthMode }); - } - - credentialResolutionStarted(config) { - this.credentialStarted = performance.now(); - const oauth = this.data.providerAuthMode === 'oauth'; - this.data.providerCredentialSource = oauth ? 'managed_identity_client_assertion' - : this.data.providerAuthMode === 'apiKey' ? 'key_vault' : 'unsupported'; - if (oauth) { - this.setIdentifier('providerTenantId', config.providerTenantId); - this.setIdentifier('functionOutboundClientId', config.outboundClientId); - this.setIdentifier('functionOutboundManagedIdentityClientId', config.outboundManagedIdentityClientId); - } - this.service('provider_credential_resolution_started', this.credentialDetails()); - } - - credentialDetails() { - return Object.fromEntries(credentialFields.map((key) => [key, this.data[key]])); - } - - credentialResolutionFinished() { - if (this.credentialStarted !== null) { - this.data.providerCredentialElapsedMs = Math.floor(performance.now() - this.credentialStarted); - this.credentialStarted = null; - } - } - - credentialResolved() { - this.credentialResolutionFinished(); - this.service('provider_credential_resolved', { - ...this.credentialDetails(), - providerCredentialElapsedMs: this.data.providerCredentialElapsedMs, - }); - } - - providerRequestBuilt(method, endpoint) { - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : null; - this.data.providerHttpMethod = httpMethods.has(normalizedMethod) ? normalizedMethod : 'other'; - const url = new URL(endpoint); - this.data.providerEndpoint = `${url.protocol}//${url.host}${url.pathname}`; - this.service('provider_request_built', { - providerHttpMethod: this.data.providerHttpMethod, - providerEndpoint: this.data.providerEndpoint, - providerScheme: 'https', - redirectsAllowed: false, - }); - } - - providerRequestStarted(timeoutMs) { - this.providerStarted = performance.now(); - this.data.providerAttempted = true; - this.data.providerTimeoutMs = timeoutMs; - this.service('provider_request_started', { - providerTimeoutMs: timeoutMs, - providerHttpMethod: this.data.providerHttpMethod, - providerEndpoint: this.data.providerEndpoint, - }); - } - - providerResponseReceived(status) { - this.data.providerHttpStatus = status; - this.service('provider_response_received', { providerHttpStatus: status }); - } - - providerRequestFinished() { - if (this.providerStarted !== null) { - this.data.providerElapsedMs = Math.floor(performance.now() - this.providerStarted); - this.providerStarted = null; - } - } - - providerResponseProcessed(manifest, parsed, outcome, httpStatus, validJson) { - const status = parsed.providerStatusName || parsed.providerStatusCode; - const knownStatus = (typeof status === 'string' || typeof status === 'number') - && status !== 'default' && Object.hasOwn(manifest.responseMapping || {}, status); - this.data.providerStatus = knownStatus ? String(status) : 'unmapped'; - this.data.providerOutcome = outcome; - this.setIdentifier('providerMessageId', parsed.providerMessageId); - if (outcome !== 'Continue') { - this.data.failureStage = 'provider_response'; - this.data.failureReason = validJson ? 'provider_rejected' : 'invalid_provider_json'; - } - this.service('provider_response_processed', { - providerHttpStatus: this.data.providerHttpStatus, - providerStatus: this.data.providerStatus, - providerOutcome: outcome, - providerMessageId: this.data.providerMessageId, - providerElapsedMs: this.data.providerElapsedMs, - httpStatus, - failureReason: this.data.failureReason, - }, httpStatus >= 500 ? 'error' : httpStatus === 200 ? 'log' : 'warn'); - } - - failure(stage, reason, httpStatus) { - this.credentialResolutionFinished(); - this.providerRequestFinished(); - this.data.failureStage = stage; - this.data.failureReason = reason; - this.service(`${stage}_failed`, { failureReason: reason, httpStatus }, - httpStatus >= 500 ? 'error' : 'warn'); - } - - responsePrepared(httpStatus, containsNonce, containsCorrelationId) { - this.data.responseContainsNonce = containsNonce; - this.data.responseContainsCorrelationId = containsCorrelationId; - this.service('response_prepared', { - httpStatus, - responseContainsNonce: containsNonce, - responseContainsCorrelationId: containsCorrelationId, - }); - } - - complete(httpStatus) { - this.credentialResolutionFinished(); - this.providerRequestFinished(); - this.context.log(JSON.stringify({ - logType: 'request', - eventName: 'request_completed', - ...this.data, - httpStatus, - result: httpStatus === 200 ? (this.data.evaluation ? 'evaluated' : 'accepted') : 'failed', - elapsedMs: Math.floor(performance.now() - this.started), - })); - } -} - -module.exports = { RequestLog }; diff --git a/javascript/test/credential-cache.test.js b/javascript/test/credential-cache.test.js index fbf7786..08b2bf3 100644 --- a/javascript/test/credential-cache.test.js +++ b/javascript/test/credential-cache.test.js @@ -3,7 +3,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { inspect } = require('node:util'); -const { ApiKeyCache, AccessTokenCache, ProviderCredentials } = require('../src/functions/credentials'); +const { ApiKeyCache, AccessTokenCache, CredentialTokenService } = require('../src/functions/credentials'); const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('../src/functions/config'); @@ -52,7 +52,7 @@ test('library-backed bundle shares concurrent reads, serves during refresh, and await gate; return { value: `${name}-${version}` }; }); - const manager = new ProviderCredentials({ cacheOptions: time.options }); + const manager = new CredentialTokenService({ cacheOptions: time.options }); try { const pending = Array.from({ length: 20 }, () => manager.resolve(auth, config)); await flush(); @@ -82,7 +82,7 @@ test('partial refresh failure retains the old pair only until hard expiry, with return { value: name }; }); const failures = []; - const manager = new ProviderCredentials({ cacheOptions: time.options, reportFailure: (kind) => failures.push(kind) }); + const manager = new CredentialTokenService({ cacheOptions: time.options, reportFailure: (kind) => failures.push(kind) }); try { await manager.resolve(auth, config); fail = true; @@ -113,7 +113,7 @@ test('invalid or disabled secret values are never published', async (t) => { for (const invalid of [{ value: '' }, { value: 'bad', properties: { enabled: false } }, { value: 'bad', properties: { notBefore: new Date(time.now + 3600000) } }, { value: 'bad', properties: { expiresOn: new Date(time.now) } }]) { - const manager = new ProviderCredentials({ cacheOptions: time.options, reportFailure() {} }); + const manager = new CredentialTokenService({ cacheOptions: time.options, reportFailure() {} }); getSecret.mock.mockImplementation(async () => invalid); await assert.rejects(manager.resolve(auth, config), /unavailable/); manager.close(); @@ -131,7 +131,7 @@ test('SDK credentials are reused, one refresh loop warms both tokens, and reads await this.getAssertion(); return { token: 'PRIVATE-PROVIDER', expiresOnTimestamp: expiry, refreshAfterTimestamp: time.now + 10000 }; }); - const manager = new ProviderCredentials({ cacheOptions: time.options, reportFailure() {} }); + const manager = new CredentialTokenService({ cacheOptions: time.options, reportFailure() {} }); try { const results = await Promise.all(Array.from({ length: 20 }, () => manager.resolve({ mode: 'oauth' }, oauth))); assert.ok(results.every((value) => value.accessToken === 'PRIVATE-PROVIDER')); @@ -161,24 +161,24 @@ test('only the selected cache is created, and new configuration uses a new worke token: 'token', expiresOnTimestamp: time.now + 3600000, })); const vault = t.mock.method(SecretClient.prototype, 'getSecret', () => assert.fail('OAuth must not read Key Vault')); - let manager = new ProviderCredentials({ cacheOptions: time.options }); + let manager = new CredentialTokenService({ cacheOptions: time.options }); try { await manager.resolve({ mode: 'oauth' }, oauth); await manager.resolve({ mode: 'oauth' }, oauth); assert.equal(provider.mock.callCount(), 1); assert.ok(manager.current instanceof AccessTokenCache); manager.close(); - manager = new ProviderCredentials({ cacheOptions: time.options }); + manager = new CredentialTokenService({ cacheOptions: time.options }); await manager.resolve({ mode: 'oauth' }, { ...oauth, providerScope: 'different-scope' }); assert.notEqual(provider.mock.calls[0].this, provider.mock.calls[1].this); manager.close(); - manager = new ProviderCredentials({ cacheOptions: time.options }); + manager = new CredentialTokenService({ cacheOptions: time.options }); await manager.resolve({ mode: 'oauth' }, { ...oauth, outboundClientId: 'different-app' }); assert.notEqual(provider.mock.calls[1].this, provider.mock.calls[2].this); assert.equal(time.timerCount, 1); assert.equal(vault.mock.callCount(), 0); manager.close(); - manager = new ProviderCredentials({ cacheOptions: time.options, reportFailure() {} }); + manager = new CredentialTokenService({ cacheOptions: time.options, reportFailure() {} }); await assert.rejects(manager.resolve({ mode: 'oauth' }, { ...oauth, providerScope: '' }), /unavailable/); assert.equal(time.timerCount, 0); await manager.resolve({ mode: 'oauth' }, oauth); @@ -190,13 +190,13 @@ test('API-key mode never creates an access-token credential and unknown modes st const time = clock(); t.mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'key' })); const token = t.mock.method(ClientAssertionCredential.prototype, 'getToken', () => assert.fail('Unexpected OAuth')); - const manager = new ProviderCredentials({ cacheOptions: time.options }); + const manager = new CredentialTokenService({ cacheOptions: time.options }); try { await manager.resolve(auth, config); assert.ok(manager.current instanceof ApiKeyCache); assert.equal(token.mock.callCount(), 0); } finally { manager.close(); } - const invalid = new ProviderCredentials({ cacheOptions: time.options, reportFailure() {} }); + const invalid = new CredentialTokenService({ cacheOptions: time.options, reportFailure() {} }); await assert.rejects(invalid.resolve({ mode: 'unknown' }, config), /unavailable/); assert.equal(invalid.current, null); assert.equal(time.timerCount, 0); @@ -211,7 +211,7 @@ test('shutdown aborts a pending read, prevents late publication, and cannot be r await gate; return { value: 'PRIVATE-LATE-KEY' }; }); - const manager = new ProviderCredentials({ cacheOptions: time.options }); + const manager = new CredentialTokenService({ cacheOptions: time.options }); const pending = manager.resolve(auth, config); const rejected = assert.rejects(pending, /unavailable/); await flush(); diff --git a/javascript/test/credential-sdk.test.js b/javascript/test/credential-sdk.test.js index a789b9b..1508872 100644 --- a/javascript/test/credential-sdk.test.js +++ b/javascript/test/credential-sdk.test.js @@ -17,8 +17,8 @@ const load = mock.method(Module, '_load', function (name, ...args) { if (name !== '@azure/core-rest-pipeline') return originalLoad.call(this, name, ...args); return { ...pipeline, createDefaultHttpClient: () => state.transport }; }); -let ProviderCredentials; -try { ({ ProviderCredentials } = require('../src/functions/credentials')); } +let CredentialTokenService; +try { ({ CredentialTokenService } = require('../src/functions/credentials')); } finally { load.mock.restore(); } const envKeys = [ @@ -99,7 +99,7 @@ function config() { test('real SDK sees one initial Entra exchange and none on concurrent or later warm requests', async () => { let now = Date.now(); - const manager = new ProviderCredentials({ + const manager = new CredentialTokenService({ cacheOptions: { now: () => now, schedule: () => ({ unref() {} }), cancel() {} }, }); const settings = config(); @@ -121,7 +121,7 @@ test('real SDK sees one initial Entra exchange and none on concurrent or later w test('the cache-owned acquisition budget aborts actual SDK transport and does not cache a late token', async () => { const failures = []; - const manager = new ProviderCredentials({ reportFailure: (kind) => failures.push(kind) }); + const manager = new CredentialTokenService({ reportFailure: (kind) => failures.push(kind) }); const settings = config(); state.wait = 10000; try { @@ -136,7 +136,7 @@ test('the cache-owned acquisition budget aborts actual SDK transport and does no }); test('restarting with new configuration cancels old work without publishing into the replacement cache', async () => { - const manager = new ProviderCredentials({ reportFailure: () => {} }); + const manager = new CredentialTokenService({ reportFailure: () => {} }); const settings = config(); state.wait = 10000; const first = manager.resolve({ mode: 'oauth' }, settings); @@ -144,7 +144,7 @@ test('restarting with new configuration cancels old work without publishing into await waitForRequest(false); manager.close(); state.wait = 5; - const replacement = new ProviderCredentials({ reportFailure() {} }); + const replacement = new CredentialTokenService({ reportFailure() {} }); const result = await replacement.resolve({ mode: 'oauth' }, { ...settings, outboundClientId: crypto.randomUUID() }); await firstRejected; try { @@ -156,7 +156,7 @@ test('restarting with new configuration cancels old work without publishing into test('the acquisition deadline aborts real managed-identity transport before another refresh starts', async () => { let now = Date.now(); - const manager = new ProviderCredentials({ + const manager = new CredentialTokenService({ reportFailure: () => {}, cacheOptions: { now: () => now, schedule: () => ({ unref() {} }), cancel() {} }, }); @@ -177,7 +177,7 @@ test('the acquisition deadline aborts real managed-identity transport before ano }); test('shutdown aborts managed-identity transport and cannot restart acquisition', async () => { - const manager = new ProviderCredentials({ reportFailure: () => {} }); + const manager = new CredentialTokenService({ reportFailure: () => {} }); const settings = config(); state.miWait = 10000; const pending = manager.resolve({ mode: 'oauth' }, settings); @@ -198,7 +198,7 @@ test('shutdown aborts managed-identity transport and cannot restart acquisition' }); test('Key Vault acquisition also aborts its real managed-identity transport', async () => { - const manager = new ProviderCredentials({ reportFailure: () => {} }); + const manager = new CredentialTokenService({ reportFailure: () => {} }); const settings = readConfig({ KEY_VAULT_URL: 'https://unit.vault.azure.net', AZURE_CLIENT_ID: crypto.randomUUID(), }); @@ -218,7 +218,7 @@ test('the default Azure HTTP client closes a stalled managed-identity socket on const { createServer } = require('node:http'); const { setTimeout: delay } = require('node:timers/promises'); const { ClientAssertionCredential } = require('@azure/identity'); - const { ProviderCredentials } = require('./src/functions/credentials'); + const { CredentialTokenService } = require('./src/functions/credentials'); ClientAssertionCredential.prototype.getToken = async function () { await this.getAssertion(); throw new Error('The synthetic managed-identity request must not complete'); @@ -239,7 +239,7 @@ test('the default Azure HTTP client closes a stalled managed-identity socket on } process.env.IDENTITY_ENDPOINT = 'http://127.0.0.1:' + server.address().port + '/identity'; process.env.IDENTITY_HEADER = 'synthetic-header'; - const manager = new ProviderCredentials({ reportFailure: () => {} }); + const manager = new CredentialTokenService({ reportFailure: () => {} }); try { await assert.rejects(manager.resolve({ mode: 'oauth' }, { providerTenantId: '11111111-1111-1111-1111-111111111111', diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js deleted file mode 100644 index 5ed1eba..0000000 --- a/javascript/test/dispatch.test.js +++ /dev/null @@ -1,351 +0,0 @@ -'use strict'; - -const { test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); -const { SecretClient } = require('@azure/keyvault-secrets'); -const { AppConfig, readConfig } = require('../src/functions/config'); -const { DeliveryContext, ParsedResponse } = require('../src/functions/models'); -const fixtures = require('../../tests/fixtures/contract.json'); -const { inspect } = require('node:util'); -const { AzureLogger } = require('@azure/logger'); -const { ProviderCredentials, providerCredentials } = require('../src/functions/credentials'); -const { - dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus, - parseEnvelope, parseProviderTimeout, isValidProviderUrl, contextToDispatch, resolveProviderCredential, -} = require('../src/functions/dispatch'); -let credentials; -beforeEach((t) => { - credentials = new ProviderCredentials(); - t.mock.method(providerCredentials, 'resolve', (...args) => credentials.resolve(...args)); -}); -afterEach(() => credentials.close()); -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.ok(config instanceof AppConfig); - assert.equal(inspect(config), '[AppConfig]'); - 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); -}); - -test('request models preserve content and accept valid TTL boundaries', () => { - const context = DeliveryContext.fromPayload({ nonce: 'test-nonce', phoneNumber: dispatch.destination, message: dispatch.message }); - assert.ok(context instanceof DeliveryContext); - assert.ok(context.isComplete); - assert.equal(context.message, dispatch.message); - assert.equal(inspect(context), '[DeliveryContext]'); - for (const payload of [null, [], 'text', 1]) assert.equal(DeliveryContext.fromPayload(payload), null); - assert.equal(DeliveryContext.fromPayload({ nonce: 123, phoneNumber: 'phone', message: 'text' }).isComplete, false); - assert.ok(parseEnvelope(envelope()).envelope); - assert.ok(parseEnvelope(envelope({ ttlSeconds: 1 })).envelope); - assert.ok(parseEnvelope(envelope({ ttlSeconds: 2147483647 })).envelope); -}); - -test('envelope parser rejects invalid inputs with the contract reason', () => { - for (const fixture of fixtures.badRequests) { - // Malformed JSON is handled before the parser receives an object. - if (fixture.reason === 'invalid JSON body') continue; - const payload = fixture.rawBody !== undefined ? JSON.parse(fixture.rawBody) : envelope(fixture.overrides); - const result = parseEnvelope(payload); - assert.equal(result.error, fixture.reason, fixture.name); - assert.equal(result.envelope, undefined, fixture.name); - } -}); - -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('Soprano uses the selected endpoint and OAuth bearer token', () => { - const request = getProvider('soprano').adapter.buildRequest({ ...input, env: undefined, - endpoint: `${input.endpoint}/oauth/messages`, - credential: { mode: 'oauth', accessToken: 'provider-token' } }); - assert.equal(request.url, 'https://provider.example/oauth/messages'); - assert.equal(request.method, 'POST'); - assert.deepEqual(request.headers, { 'Content-Type': 'application/json', Accept: 'application/json', - Authorization: 'Bear' + 'er provider-token' }); - assert.deepEqual(JSON.parse(request.body), { text: dispatch.message, destination: '15551234567', - messageTypes: ['sms'], correlationId: 'correlation-id', shutterMode: false }); - const response = getProvider('soprano').adapter.parseResponse({ httpStatus: 201, ok: true, - json: { id: 123, status: 'ENROUTE' } }); - assert.deepEqual(response, new ParsedResponse({ success: true, providerHttpStatus: 201, - providerMessageId: '123', providerStatusName: 'ENROUTE' })); - assert.equal(inspect(response), '[ParsedResponse]'); -}); - -test('Soprano Voice sends structured speech with OAuth', () => { - const request = getProvider('soprano').adapter.buildRequest({ ...input, channel: 'voice', - endpoint: `${input.endpoint}/oauth/voice`, - dispatch: { ...dispatch, locale: 'fr-FR', textToVoice: { language: 'override', gender: 2, loop: 9 } }, - credential: { mode: 'oauth', accessToken: 'provider-token' } }); - assert.equal(request.url, `${input.endpoint}/oauth/voice`); - assert.deepEqual(request.headers, { 'Content-Type': 'application/json', Accept: 'application/json', - Authorization: 'Bear' + 'er provider-token' }); - assert.deepEqual(JSON.parse(request.body), { destination: '15551234567', messageTypes: ['voice'], - correlationId: 'correlation-id', shutterMode: false, - voice: { text2voice: { beforePasswordText: ' Your code is ', password: '918273', - afterPasswordText: '.\n', language: 'fr-FR', gender: 1, loop: 2 } } }); - for (const locale of [undefined, null, '', ' ', { untrusted: true }]) { - const fallbackRequest = getProvider('soprano').adapter.buildRequest({ - ...input, channel: 'voice', dispatch: { ...dispatch, locale }, - credential: { mode: 'oauth', accessToken: 'provider-token' }, - }); - assert.equal(JSON.parse(fallbackRequest.body).voice.text2voice.language, 'en-US'); - } - assert.throws(() => getProvider('soprano').adapter.buildRequest({ - ...input, channel: 'voice', dispatch: { ...dispatch, message: 'Your code is unavailable.' }, - }), /voice message does not contain a six-digit passcode/); -}); - -test('Soprano SMS remains independent from voice synthesis settings', () => { - const request = getProvider('soprano').adapter.buildRequest({ ...input, - dispatch: { ...dispatch, textToVoice: { language: 'override', gender: 2, loop: 9 } }, - credential: { mode: 'oauth', accessToken: 'provider-token' } }); - assert.deepEqual(JSON.parse(request.body), { text: dispatch.message, destination: '15551234567', - messageTypes: ['sms'], correlationId: 'correlation-id', shutterMode: false }); -}); - -test('App-auth SMS preserves its request and normalizes acceptance', () => { - 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); - const response = getProvider('infobip').adapter.parseResponse({ httpStatus: 200, ok: true, - json: { messages: [{ messageId: 'message-id', status: { groupName: 'PENDING' } }] } }); - assert.deepEqual(response, new ParsedResponse({ success: true, providerHttpStatus: 200, - providerMessageId: 'message-id', providerStatusName: 'PENDING' })); -}); - -test('Telesign EPP uses the selected endpoint with the same Basic-auth JSON contract for SMS and voice', () => { - for (const [channel, locale] of [['sms', 'en'], ['voice', 'en'], - ['sms', undefined], ['sms', ''], ['sms', { untrusted: true }]]) { - const request = getProvider('telesign').adapter.buildRequest({ ...input, channel, - endpoint: `https://verify.telesign.com/epp/${channel}`, dispatch: { ...dispatch, locale } }); - assert.equal(request.url, `https://verify.telesign.com/epp/${channel}`); - assert.equal(request.method, 'POST'); - assert.deepEqual(request.headers, { Authorization: `Basic ${Buffer.from('id:key').toString('base64')}`, - 'Content-Type': 'application/json', Accept: 'application/json' }); - const expectedText = channel === 'voice' - ? ' Your code is 9, 1, 8, 2, 7, 3.\n Your code is 9, 1, 8, 2, 7, 3.\n' - : dispatch.message; - assert.deepEqual(JSON.parse(request.body), { - recipient: { phone_number: dispatch.destination }, - message: locale === 'en' ? { text: expectedText, language: 'en' } : { text: expectedText }, - channels: [{ channel }], correlation_id: dispatch.correlationId, - }); - } - const response = getProvider('telesign').adapter.parseResponse({ httpStatus: 200, ok: true, - json: { reference_id: 'message-id', status: { code: 290 } } }); - assert.deepEqual(response, new ParsedResponse({ success: true, providerHttpStatus: 200, - providerMessageId: 'message-id', providerStatusCode: '290' })); -}); - -test('Telesign Voice paces only six-digit numeric runs and repeats the full message', () => { - const message = 'Code 001234; ref 1234567; alternate 654321.'; - const request = getProvider('telesign').adapter.buildRequest({ - ...input, - channel: 'voice', - dispatch: { ...dispatch, message }, - }); - assert.equal(JSON.parse(request.body).message.text, - 'Code 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1. ' - + 'Code 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1.'); -}); - -test('Telesign EPP rejects invalid recipients and fails closed on unknown status', () => { - const { adapter, manifest } = getProvider('telesign'); - for (const destination of ['15551234567', '+0123', '+1', '+1234567890123456', '+123\n', '+123\r', '+12 34', null]) { - assert.throws(() => adapter.buildRequest({ ...input, dispatch: { ...dispatch, destination } }), /invalid recipient/); - } - assert.throws(() => adapter.buildRequest({ ...input, channel: 'email' }), /unsupported channel/); - for (const correlationId of [undefined, null, '', 123, true, [], { invalid: true }]) { - const request = adapter.buildRequest({ ...input, dispatch: { ...dispatch, correlationId } }); - assert.equal(JSON.parse(request.body).correlation_id, dispatch.messageId); - } - for (const code of [undefined, null, {}, true, '290', 999]) { - const parsed = adapter.parseResponse({ httpStatus: 200, ok: true, json: { status: { code } } }); - assert.equal(resolveOutcome(manifest, parsed), 'Fail'); - } - for (const [code, ok, expected] of [[290, true, 'Continue'], [100, true, 'Continue'], [290, false, 'Fail'], - [3001, true, 'Continue'], [3001, false, 'Fail']]) { - const parsed = adapter.parseResponse({ httpStatus: ok ? 200 : 500, ok, - json: { reference_id: 'reference', status: { code, description: 'status detail' } } }); - assert.equal(parsed.providerStatusDescription, 'status detail'); - assert.equal(resolveOutcome(manifest, parsed), expected); - } -}); - -test('static-Bearer SMS preserves its batch request and normalizes acceptance', () => { - const request = getProvider('sinch').adapter.buildRequest(input); - assert.equal(request.url, 'https://provider.example/xms/v1/plan/batches'); - assert.equal(request.headers.Authorization, 'Bearer key'); - assert.equal(request.headers['Content-Type'], 'application/json'); - assert.equal(JSON.parse(request.body).body, dispatch.message); - const response = getProvider('sinch').adapter.parseResponse({ httpStatus: 200, ok: true, json: { id: 'message-id' } }); - assert.deepEqual(response, new ParsedResponse({ success: true, providerHttpStatus: 200, - providerMessageId: 'message-id', providerStatusName: 'Dispatched' })); -}); - -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('missing API-key or OAuth settings 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: name === 'telesign-api-key' ? '' : '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'], - ]) { - credentials.close(); - credentials = new ProviderCredentials(); - 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]); - } - 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' }]) { - credentials.close(); - credentials = new ProviderCredentials(); - 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 + 3); - assert.equal(new Set(getSecret.mock.calls.map((call) => call.this)).size, 5); - assert.equal(fetchMock.mock.callCount(), 0); -}); - -test('Soprano OAuth reuses setup identities and selected scope with private bounded tokens', async (t) => { - const identityToken = t.mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ - token: 'assertion-token', expiresOnTimestamp: Date.now() + 3600000, - })); - const providerToken = t.mock.method(ClientAssertionCredential.prototype, 'getToken', async function () { - assert.equal(await this.getAssertion(), 'assertion-token'); - return { token: 'provider-token', expiresOnTimestamp: Date.now() + 3600000 }; - }); - const config = readConfig({ - EPP_PROVIDER_TENANT_ID: '11111111-1111-1111-1111-111111111111', - EPP_PROVIDER_SCOPE: 'api://provider/.default', - EPP_OUTBOUND_CLIENT_ID: '22222222-2222-2222-2222-222222222222', - EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333', - }); - const credential = await resolveProviderCredential({ mode: 'oauth' }, config); - assert.equal(credential.accessToken, 'provider-token'); - assert.equal(JSON.stringify(credential), '{"mode":"oauth"}'); - assert.equal(providerToken.mock.calls[0].arguments[0], 'api://provider/.default'); - assert.equal(identityToken.mock.calls[0].arguments[0], 'api://AzureADTokenExchange/.default'); - const signal = providerToken.mock.calls[0].arguments[1].abortSignal; - assert.ok(signal instanceof AbortSignal); - assert.ok(identityToken.mock.calls[0].arguments[1].abortSignal instanceof AbortSignal); - credentials.close(); - credentials = new ProviderCredentials(); - await resolveProviderCredential({ mode: 'oauth' }, { ...config, providerScope: 'api://another/.default' }); - assert.notEqual(providerToken.mock.calls[1].this, providerToken.mock.calls[0].this); - assert.equal(providerToken.mock.calls[1].arguments[0], 'api://another/.default'); - for (const property of ['providerTenantId', 'outboundClientId', 'outboundManagedIdentityClientId']) { - credentials.close(); - credentials = new ProviderCredentials(); - await resolveProviderCredential({ mode: 'oauth' }, { ...config, - [property]: '44444444-4444-4444-4444-444444444444' }); - assert.notEqual(providerToken.mock.calls.at(-1).this, providerToken.mock.calls[0].this); - } - for (const stage of ['token', 'assertion']) { - for (const invalid of [null, { token: '' }, { token: ' ' }, { token: false }, - { token: 'stale', expiresOnTimestamp: Date.now() + 10000 }, { token: 'missing-expiry' }]) { - const method = stage === 'token' ? providerToken : identityToken; - credentials.close(); - credentials = new ProviderCredentials(); - method.mock.mockImplementation(async () => invalid); - if (stage === 'assertion') providerToken.mock.mockImplementation(async function () { - await this.getAssertion(); - return { token: 'provider-token', expiresOnTimestamp: Date.now() + 3600000 }; - }); - await assert.rejects(resolveProviderCredential({ mode: 'oauth' }, config), /^Error: provider credential unavailable$/); - } - } -}); - -test('Soprano OAuth suppresses SDK diagnostics only during token acquisition', async (t) => { - const entries = []; - t.mock.method(AzureLogger, 'log', (...args) => entries.push(args)); - let release; - const waiting = new Promise(resolve => { release = resolve; }); - t.mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ - token: 'assertion', expiresOnTimestamp: Date.now() + 3600000, - })); - t.mock.method(ClientAssertionCredential.prototype, 'getToken', async () => { - AzureLogger.log('PRIVATE-TOKEN-AND-ACCOUNT'); - await waiting; - AzureLogger.log('PRIVATE-SDK-FAILURE'); - throw new Error('PRIVATE-TOKEN-EXCEPTION'); - }); - const pending = resolveProviderCredential({ mode: 'oauth' }, readConfig({ - EPP_PROVIDER_TENANT_ID: '11111111-1111-1111-1111-111111111111', - EPP_PROVIDER_SCOPE: 'api://provider/.default', - EPP_OUTBOUND_CLIENT_ID: '22222222-2222-2222-2222-222222222222', - EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333', - })); - AzureLogger.log('unrelated request'); - release(); - await assert.rejects(pending, /^Error: provider credential unavailable$/); - AzureLogger.log('after acquisition'); - assert.deepEqual(entries, [['unrelated request'], ['after acquisition']]); -}); diff --git a/javascript/test/provider-flow.test.js b/javascript/test/provider-flow.test.js new file mode 100644 index 0000000..cca8571 --- /dev/null +++ b/javascript/test/provider-flow.test.js @@ -0,0 +1,297 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { inspect } = require('node:util'); +const fixtures = require('../../tests/fixtures/contract.json'); +const { AppConfig, readConfig } = require('../src/functions/config'); +const { parseEntraPayload } = require('../src/functions/entraPayload'); +const { DeliveryContext, OtpDelivery } = require('../src/functions/delivery'); +const { OUTCOME } = require('../src/functions/providerResult'); +const { selectProvider } = require('../src/functions/providers'); +const { + ProviderTransportError, + isValidProviderUrl, + parseProviderTimeout, + sendProviderRequest, +} = require('../src/functions/providerTransport'); +const { createRequestContext } = require('../src/functions/logging'); + +const delivery = new OtpDelivery({ + phoneNumber: '+15551234567', + message: ' Your code is 001234; ref 1234567; alternate 654321.\n', + channel: 'sms', + messageId: 'message-id', + correlationId: 'correlation-id', + locale: 'fr-FR', +}); +const input = { + channel: 'sms', + endpoint: 'https://provider.example', + delivery, + credential: { mode: 'apiKey', identity: 'id', secret: 'key' }, + env: { SINCH_SERVICE_PLAN_ID: 'plan' }, +}; +const response = (json, providerHttpStatus = 200, validJson = true) => ({ + json, + providerHttpStatus, + ok: providerHttpStatus >= 200 && providerHttpStatus < 300, + validJson, + elapsedMs: 1, +}); +const envelope = (overrides = {}) => ({ + type: 'microsoft.mfa.otpDeliver.v1', + channel: 1, + mode: 1, + encryptedDeliveryContext: 'a.b.c.d.e', + ...overrides, +}); + +test('config and fixed provider lookup have no default or registry', () => { + const config = readConfig({ EPP_PROVIDER_NAME: ' SiNcH ', EPP_PROVIDER_TIMEOUT_MS: ' 0012 ' }); + assert.ok(config instanceof AppConfig); + assert.equal(inspect(config), '[AppConfig]'); + assert.deepEqual([config.providerName, config.providerTimeoutMs], ['sinch', ' 0012 ']); + assert.equal(selectProvider(' SiNcH ').name, 'sinch'); + assert.equal(selectProvider('unknown'), null); + assert.equal(selectProvider(''), null); +}); + +test('validated Entra payload and delivery models preserve content', () => { + const parsed = parseEntraPayload(envelope({ channel: 'voice', mode: 'evaluation', ttlSeconds: 1 })); + assert.equal(parsed.error, undefined); + assert.equal(parsed.payload.channelName, 'voice'); + assert.equal(parsed.payload.isEvaluation, true); + assert.equal(inspect(parsed.payload), '[EntraSendOtpPayload]'); + const context = DeliveryContext.fromPayload({ + nonce: 'nonce', + phoneNumber: delivery.phoneNumber, + message: delivery.message, + }); + assert.ok(context.isComplete); + assert.equal(context.message, delivery.message); + assert.equal(inspect(context), '[DeliveryContext]'); + assert.equal(inspect(delivery), '[OtpDelivery]'); + for (const value of [null, [], 'text', 1]) assert.equal(DeliveryContext.fromPayload(value), null); +}); + +test('payload validation preserves exact shared reasons and order', () => { + for (const fixture of fixtures.badRequests) { + if (fixture.reason === 'invalid JSON body') continue; + const value = fixture.rawBody !== undefined + ? JSON.parse(fixture.rawBody) + : envelope(fixture.overrides); + assert.equal(parseEntraPayload(value).error, fixture.reason, fixture.name); + } +}); + +test('provider URLs and timeout parsing preserve safety boundaries and cap', () => { + 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', + 'https://provider.example\\path', 'https://provider.example/\npath']) { + assert.equal(isValidProviderUrl(url), false, url); + } + assert.equal(isValidProviderUrl('https://provider.example:65535/path?secret=hidden'), true); + for (const value of [null, '0', '-1', '1e3']) assert.equal(parseProviderTimeout(value), 1500); + assert.equal(parseProviderTimeout(' 0012 '), 12); + assert.equal(parseProviderTimeout('999999999999999999999999999'), 2500); +}); + +test('Soprano owns OAuth request creation and rendered voice splitting', () => { + const soprano = selectProvider('soprano'); + assert.deepEqual(soprano.credentialSpec, { mode: 'oauth' }); + const sms = soprano.createRequest({ + ...input, + endpoint: 'https://provider.example/messages', + credential: { mode: 'oauth', accessToken: 'token' }, + }); + assert.equal(sms.url, 'https://provider.example/messages'); + assert.equal(sms.headers.Authorization, 'Bearer token'); + assert.deepEqual(JSON.parse(sms.body), { + destination: '15551234567', + messageTypes: ['sms'], + correlationId: 'correlation-id', + shutterMode: false, + text: delivery.message, + }); + const voice = soprano.createRequest({ + ...input, + channel: 'voice', + credential: { mode: 'oauth', accessToken: 'token' }, + }); + assert.deepEqual(JSON.parse(voice.body).voice.text2voice, { + beforePasswordText: ' Your code is ', + password: '001234', + afterPasswordText: '; ref 1234567; alternate 654321.\n', + language: 'fr-FR', + gender: 1, + loop: 2, + }); + assert.throws(() => soprano.createRequest({ + ...input, + channel: 'voice', + delivery: new OtpDelivery({ ...delivery, message: 'No code.' }), + credential: { mode: 'oauth', accessToken: 'token' }, + }), /six-digit passcode/); +}); + +test('Soprano accepts root or first array object with strict status and flexible IDs', () => { + const soprano = selectProvider('soprano'); + for (const [json, id, status, outcome] of [ + [{ id: 123, status: 'enroute' }, '123', 'ENROUTE', OUTCOME.CONTINUE], + [[{ id: '', messageId: 456, state: 'accepted' }], '456', 'ACCEPTED', OUTCOME.CONTINUE], + [{ id: {}, messageId: 'fallback', status: '', state: 'ACCEPTED' }, 'fallback', 'UNKNOWN', OUTCOME.FAIL], + [{ status: false, state: 'ACCEPTED' }, null, 'UNKNOWN', OUTCOME.FAIL], + [[], null, 'UNKNOWN', OUTCOME.FAIL], + ]) { + const result = soprano.interpretResponse(response(json)); + assert.deepEqual([result.providerMessageId, result.providerStatusName, result.outcome], + [id, status, outcome]); + } +}); + +test('Infobip accepts only strict recognized status strings', () => { + const infobip = selectProvider('infobip'); + const request = infobip.createRequest(input); + assert.equal(request.headers.Authorization, 'App key'); + assert.equal(JSON.parse(request.body).messages[0].content.text, delivery.message); + for (const [status, expected] of [ + ['ACCEPTED', OUTCOME.CONTINUE], + ['pending', OUTCOME.CONTINUE], + ['DELIVERED', OUTCOME.CONTINUE], + ['REJECTED', OUTCOME.FAIL], + [290, OUTCOME.FAIL], + [true, OUTCOME.FAIL], + [{}, OUTCOME.FAIL], + [null, OUTCOME.FAIL], + ]) { + const result = infobip.interpretResponse(response({ + messages: [{ messageId: 'provider-id', status: { groupName: status } }], + })); + assert.equal(result.outcome, expected); + assert.equal(result.statusRecognized, typeof status === 'string' + && ['ACCEPTED', 'PENDING', 'DELIVERED', 'REJECTED'].includes(status.toUpperCase())); + } +}); + +test('Sinch HTTP success requires a nonblank string ID and may infer Dispatched', () => { + const sinch = selectProvider('sinch'); + const request = sinch.createRequest(input); + assert.equal(request.headers.Authorization, 'Bearer key'); + assert.equal(JSON.parse(request.body).body, delivery.message); + for (const [json, outcome, reason, status] of [ + [{ id: 'batch-id' }, OUTCOME.CONTINUE, null, 'Dispatched'], + [{ callId: 'call-id', status: 'Delivered' }, OUTCOME.CONTINUE, null, 'Delivered'], + [{ id: ' ', status: 'Delivered' }, OUTCOME.FAIL, 'missing_provider_message_id', 'Delivered'], + [{ id: 123, status: 'Delivered' }, OUTCOME.FAIL, 'missing_provider_message_id', 'Delivered'], + [{ id: 'batch-id', status: true }, OUTCOME.FAIL, 'unrecognized_provider_status', null], + ]) { + const result = sinch.interpretResponse(response(json)); + assert.deepEqual([result.outcome, result.failureReason, result.providerStatusName], + [outcome, reason, status]); + } +}); + +test('Telesign request validates E.164 and voice pacing remains unchanged', () => { + const telesign = selectProvider('telesign'); + const voice = telesign.createRequest({ ...input, channel: 'voice' }); + assert.deepEqual(telesign.credentialSpec, { + mode: 'apiKey', + keyVaultSecretName: 'telesign-api-key', + identityKeyVaultSecretName: 'telesign-customer-id', + }); + assert.equal(JSON.parse(voice.body).message.text, + ' Your code is 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1.\n' + + ' Your code is 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1.\n'); + for (const phoneNumber of ['15551234567', '+0123', '+1', '+1234567890123456', '+123\n']) { + assert.throws(() => telesign.createRequest({ + ...input, + delivery: new OtpDelivery({ ...delivery, phoneNumber }), + }), /invalid recipient/); + } +}); + +test('Telesign status.code must be an integer number', () => { + const telesign = selectProvider('telesign'); + for (const [code, outcome, normalized] of [ + [290, OUTCOME.CONTINUE, '290'], + [3001, OUTCOME.CONTINUE, '3001'], + [true, OUTCOME.FAIL, 'UNKNOWN'], + ['290', OUTCOME.FAIL, 'UNKNOWN'], + [290.5, OUTCOME.FAIL, 'UNKNOWN'], + [null, OUTCOME.FAIL, 'UNKNOWN'], + ]) { + const result = telesign.interpretResponse(response({ status: { code } })); + assert.deepEqual([result.outcome, result.providerStatusCode], [outcome, normalized]); + } +}); + +test('providers fail closed with fixed safe classifications', () => { + const cases = [ + [selectProvider('infobip'), response(null, 200, false), 'invalid_provider_json', 502], + [selectProvider('soprano'), response({ status: 'PRIVATE' }), 'unrecognized_provider_status', 502], + [selectProvider('infobip'), response({ messages: [{ status: { name: 'REJECTED' } }] }), + 'provider_rejected', 502], + [selectProvider('telesign'), response({ status: { code: 290 } }, 429), 'provider_http_error', 429], + [selectProvider('sinch'), response({ id: 'id' }, 500), 'provider_http_error', 502], + ]; + for (const [provider, providerResponse, reason, httpStatus] of cases) { + const result = provider.interpretResponse(providerResponse); + assert.deepEqual([result.failureReason, result.httpStatus], [reason, httpStatus]); + } +}); + +test('shared transport uses manual redirects, omits query from logs, and awaits the body', async (t) => { + const records = []; + const context = createRequestContext({ + invocationId: 'invocation', + log: (value) => records.push(JSON.parse(value)), + warn: (value) => records.push(JSON.parse(value)), + error: (value) => records.push(JSON.parse(value)), + }, 'request-id', null, null); + let release; + const body = new Promise((resolve) => { release = resolve; }); + const fetchMock = t.mock.method(global, 'fetch', async () => ({ + ok: true, + status: 200, + text: () => body, + })); + let settled = false; + const pending = sendProviderRequest({ + url: 'https://provider.example/api/send?secret=PRIVATE', + method: 'POST', + headers: { Authorization: 'PRIVATE-TOKEN' }, + body: '{}', + }, 1500, context).then((value) => { settled = true; return value; }); + await new Promise(setImmediate); + assert.equal(settled, false); + release('{"status":"ok"}'); + assert.equal((await pending).validJson, true); + assert.equal(fetchMock.mock.calls[0].arguments[1].redirect, 'manual'); + assert.equal(records.find((event) => event.eventName === 'provider_request_built').providerEndpoint, + 'https://provider.example/api/send'); + assert.doesNotMatch(JSON.stringify(records), /PRIVATE/); +}); + +test('shared transport AbortController covers body reading and classifies timeout', async (t) => { + const context = createRequestContext({ + invocationId: 'invocation', + log() {}, + warn() {}, + error() {}, + }, 'request-id', null, null); + const fetchMock = t.mock.method(global, 'fetch', async (_url, { signal }) => ({ + ok: true, + status: 200, + text: () => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('private')), { once: true }); + }), + })); + await assert.rejects( + sendProviderRequest({ url: 'https://provider.example', method: 'POST' }, 1, context), + (error) => error instanceof ProviderTransportError + && error.httpStatus === 504 + && error.reason === 'provider_timeout', + ); + assert.equal(fetchMock.mock.calls[0].arguments[1].signal.aborted, true); +}); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 661c2a4..68fbe6f 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -8,11 +8,11 @@ const { CompactEncrypt } = require('jose'); const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const fixtures = require('../../tests/fixtures/contract.json'); -const { getProvider } = require('../src/functions/dispatch'); -const { ProviderCredentials, providerCredentials } = require('../src/functions/credentials'); -const { RequestLog } = require('../src/functions/requestLog'); +const { + CredentialTokenService, + credentialTokenService, +} = require('../src/functions/credentials'); -// 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; let startHook; @@ -20,9 +20,15 @@ let stopHook; const originalLoad = Module._load; const registration = mock.method(Module, '_load', function (name, ...args) { if (name === '@azure/functions') { - return { app: { hook: { appStart: (callback) => { startHook = callback; }, - appTerminate: (callback) => { stopHook = callback; } }, - http: (_name, options) => { handler = options.handler; } } }; + return { + app: { + hook: { + appStart: (callback) => { startHook = callback; }, + appTerminate: (callback) => { stopHook = callback; }, + }, + http: (_name, options) => { handler = options.handler; }, + }, + }; } return originalLoad.call(this, name, ...args); }); @@ -32,45 +38,68 @@ try { registration.mock.restore(); } -const envKeys = ['EPP_ENCRYPTION_KEY_ID', 'AZURE_CLIENT_ID', 'EPP_PROVIDER_NAME', 'EPP_PROVIDER_ENDPOINT', 'EPP_PROVIDER_CHANNEL', - 'EPP_PROVIDER_TIMEOUT_MS', 'EPP_PROVIDER_AUTH_MODE', 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_SCOPE', - 'EPP_OUTBOUND_CLIENT_ID', 'EPP_OUTBOUND_MI_CLIENT_ID', 'EPP_LOG_PLAINTEXT', 'KEY_VAULT_URL', - 'EPP_DECRYPTION_KEY_PEM']; +const envKeys = [ + 'EPP_ENCRYPTION_KEY_ID', 'AZURE_CLIENT_ID', 'EPP_PROVIDER_NAME', + 'EPP_PROVIDER_ENDPOINT', 'EPP_PROVIDER_CHANNEL', 'EPP_PROVIDER_TIMEOUT_MS', + 'EPP_PROVIDER_AUTH_MODE', 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_SCOPE', + 'EPP_OUTBOUND_CLIENT_ID', 'EPP_OUTBOUND_MI_CLIENT_ID', 'KEY_VAULT_URL', + 'EPP_DECRYPTION_KEY_PEM', 'SINCH_SERVICE_PLAN_ID', +]; +const baseDelivery = { + nonce: 'PRIVATE-NONCE', + phoneNumber: '+15551234567', + message: ' PRIVATE-MESSAGE 918273.\n', + locale: 'fr-FR', + riskContext: { value: 'PRIVATE-RISK' }, +}; let savedEnv; +let service; let fetchMock; let getSecret; -let logs; -let warnings; -let records; let getToken; -let credentials; +let records; + beforeEach(() => { - credentials = new ProviderCredentials(); - mock.method(providerCredentials, 'resolve', (...args) => credentials.resolve(...args)); - mock.method(providerCredentials, 'close', () => credentials.close()); 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', + Object.assign(process.env, { 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/epp/messages', EPP_PROVIDER_AUTH_MODE: 'oauth', + KEY_VAULT_URL: 'https://unit-test.vault.azure.net', + EPP_PROVIDER_NAME: 'soprano', + EPP_PROVIDER_ENDPOINT: 'https://provider.example/epp/messages', + EPP_PROVIDER_AUTH_MODE: 'oauth', EPP_PROVIDER_TENANT_ID: '11111111-1111-1111-1111-111111111111', EPP_PROVIDER_SCOPE: 'api://provider/.default', EPP_OUTBOUND_CLIENT_ID: '22222222-2222-2222-2222-222222222222', - EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333' }); + EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333', + }); + service = new CredentialTokenService(); + mock.method(credentialTokenService, 'getCredentials', + (...args) => service.getCredentials(...args)); + mock.method(credentialTokenService, 'close', () => service.close()); mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ - token: 'PRIVATE-ASSERTION', expiresOnTimestamp: Date.now() + 3600000, + token: 'PRIVATE-ASSERTION', + expiresOnTimestamp: Date.now() + 3600000, })); getToken = mock.method(ClientAssertionCredential.prototype, 'getToken', async function () { assert.equal(await this.getAssertion(), 'PRIVATE-ASSERTION'); return { token: 'PRIVATE-OAUTH-TOKEN', expiresOnTimestamp: Date.now() + 3600000 }; }); - getSecret = mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'PRIVATE-API-KEY' })); - fetchMock = mock.method(global, 'fetch', async () => ({ ok: true, status: 201, - text: async () => JSON.stringify({ status: 'ENROUTE', id: 'provider-reference-id', description: 'PRIVATE-STATUS' }) })); + 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: 'provider-reference-id', + description: 'PRIVATE-STATUS', + }), + })); }); + afterEach(() => { - credentials.close(); + service.close(); mock.restoreAll(); for (const [key, value] of Object.entries(savedEnv)) { if (value === undefined) delete process.env[key]; @@ -78,600 +107,390 @@ afterEach(() => { } }); -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 }) +async function envelope(overrides = {}, delivery = baseDelivery, protectedHeader = {}) { + const encryptedDeliveryContext = await new CompactEncrypt(Buffer.from(JSON.stringify(delivery))) + .setProtectedHeader({ + alg: 'RSA-OAEP-256', + enc: 'A256GCM', + kid: 'PRIVATE-KID', + ...protectedHeader, + }) .encrypt(publicKey); - return { type: 'microsoft.mfa.otpDeliver.v1', channel: 1, mode: 1, ttlSeconds: 60, - correlationId: 'correlation-id', encryptedDeliveryContext, ...overrides }; + return { + type: 'microsoft.mfa.otpDeliver.v1', + channel: 1, + mode: 1, + ttlSeconds: 60, + correlationId: 'correlation-id', + encryptedDeliveryContext, + ...overrides, + }; } -const invoke = (body, headers = {}) => { - logs = []; - warnings = []; + +async function invoke(body, headers = {}) { records = []; - const capture = (level) => (value) => { - const record = JSON.parse(value); - records.push(record); - if (level === 'log') logs.push(record); - if (level === 'warn') warnings.push(record); - }; - return handler({ headers: { get: (name) => headers[name.toLowerCase()] || null }, - text: async () => typeof body === 'string' ? body : JSON.stringify(body) }, - { invocationId: 'function-invocation-id', log: capture('log'), warn: capture('warn'), error: capture('error') }) - .then((result) => { - const record = summary(); - assert.deepEqual(Object.keys(record).sort(), [...fixtures.logging.summaryFields].sort()); - assert.equal(records.at(-1), record); - assert.equal(record.httpStatus, result.status); - assert.equal(record.responseContainsNonce, Object.hasOwn(result.jsonBody, 'nonce')); - assert.equal(record.responseContainsCorrelationId, Object.hasOwn(result.jsonBody, 'correlationId')); - const prepared = records.filter((event) => event.eventName === 'response_prepared'); - assert.equal(prepared.length, 1); - assert.equal(prepared[0], records.at(-2)); - assert.equal(prepared[0].httpStatus, result.status); - assert.equal(prepared[0].responseContainsNonce, record.responseContainsNonce); - assert.equal(prepared[0].responseContainsCorrelationId, record.responseContainsCorrelationId); - assert.ok(record.elapsedMs >= 0); - for (const event of records) { - assert.equal(event.functionRequestId, record.functionRequestId); - assert.equal(event.functionInvocationId, 'function-invocation-id'); - assert.equal(event.functionName, 'SendOtp'); - } - assert.doesNotMatch(JSON.stringify(records), /PRIVATE|FORGED|918273|15551234567/); - return result; - }); -}; -function summary() { - const summaries = records.filter((record) => record.logType === 'request'); - assert.equal(summaries.length, 1); - assert.equal(summaries[0].eventName, 'request_completed'); - assert.equal(records.filter((record) => record.logType === 'service').length, records.length - 1); - return summaries[0]; + const capture = (value) => records.push(JSON.parse(value)); + const result = await handler({ + headers: { get: (name) => headers[name.toLowerCase()] ?? null }, + text: async () => typeof body === 'string' ? body : JSON.stringify(body), + }, { + invocationId: 'function-invocation-id', + log: capture, + warn: capture, + error: capture, + }); + assert.equal(records.at(-1).eventName, 'request_completed'); + assert.equal(records.at(-1).httpStatus, result.status); + assert.equal(records.filter((event) => event.eventName === 'request_completed').length, 1); + assert.equal(records.filter((event) => event.eventName === 'response_prepared').length, 1); + assert.ok(records.every((event) => event.functionRequestId + && event.functionInvocationId === 'function-invocation-id')); + assert.doesNotMatch(JSON.stringify(records), /PRIVATE|FORGED|918273|15551234567/); + return result; } -function assertFailure(result, status, error = 'provider_delivery_failed') { + +function failure(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('worker startup preloads credentials without delivery and leaves evaluation independent', async () => { +function event(name) { + return records.find((record) => record.eventName === name); +} + +test('startup prewarms only the configured provider and shutdown closes the service', async () => { await startHook(); assert.equal(getToken.mock.callCount(), 1); - assert.equal(fetchMock.mock.callCount(), 0); assert.equal(getSecret.mock.callCount(), 0); - const response = await invoke(await envelope({ mode: 2 })); - assert.equal(response.status, 200); - assert.equal(getToken.mock.callCount(), 1); assert.equal(fetchMock.mock.callCount(), 0); await invoke(await envelope()); assert.equal(getToken.mock.callCount(), 1); assert.equal(fetchMock.mock.callCount(), 1); stopHook(); - assertFailure(await invoke(await envelope()), 502); - assert.equal(getToken.mock.callCount(), 1); - assert.equal(fetchMock.mock.callCount(), 1); + failure(await invoke(await envelope()), 502); + assert.equal(event('request_failed').failureReason, 'credential_unavailable'); }); -test('worker startup without a configured provider does not acquire any credentials', async () => { +test('startup without a provider and evaluation perform no provider I/O', async () => { delete process.env.EPP_PROVIDER_NAME; await startHook(); - assert.equal(getToken.mock.callCount(), 0); - assert.equal(getSecret.mock.callCount(), 0); - assert.equal(fetchMock.mock.callCount(), 0); + const result = await invoke(await envelope({ mode: 'evaluation' })); + assert.equal(result.status, 200); + assert.equal(result.jsonBody.nonce, baseDelivery.nonce); + assert.deepEqual([getToken.mock.callCount(), getSecret.mock.callCount(), fetchMock.mock.callCount()], + [0, 0, 0]); + assert.equal(event('evaluation_completed').eventName, 'evaluation_completed'); }); -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' }; +test('shared invalid requests preserve exact reasons and never perform 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); + failure(result, 400, 'bad_request'); + assert.equal(result.jsonBody.reason, fixture.reason, fixture.name); + assert.equal(event('request_failed').failureStage, 'request_validation'); } 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 }); + const result = await invoke(await envelope({ mode: 2 }, { ...baseDelivery, ...changes })); + failure(result, 400, 'bad_request'); + assert.equal(result.jsonBody.reason, 'incomplete delivery context'); + assert.equal(event('request_failed').failureStage, 'delivery_context_validation'); } - assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); + assert.deepEqual([getToken.mock.callCount(), getSecret.mock.callCount(), fetchMock.mock.callCount()], + [0, 0, 0]); }); -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.equal(warnings.some((record) => record.eventName === 'encryption_key_id_mismatch'), false); +test('JWE algorithm policy, size/shape and bad tags fail before provider I/O', async () => { + for (const fixture of fixtures.jwe) { + const result = await invoke(await envelope({ mode: 2 }, baseDelivery, fixture)); + if (fixture.accepted) assert.equal(result.status, 200); + else failure(result, 400, 'decryption_failed'); } - assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); -}); - -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 }); - } + const body = await envelope({ mode: 2 }); + const parts = body.encryptedDeliveryContext.split('.'); + parts[4] = `${parts[4][0] === 'A' ? 'B' : 'A'}${parts[4].slice(1)}`; + for (const encryptedDeliveryContext of [ + parts.join('.'), + parts.slice(0, 4).join('.'), + 'a'.repeat(16385), + ]) { + failure(await invoke({ ...body, encryptedDeliveryContext }), 400, 'decryption_failed'); } - assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); + assert.equal(fetchMock.mock.callCount(), 0); }); -test('JWE authenticates the original protected-header bytes, not reserialized JSON', async () => { +test('JWE authenticates original protected bytes and caches PEM-compatible keys', 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 contentKey = crypto.randomBytes(32); const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const cipher = crypto.createCipheriv('aes-256-gcm', contentKey, 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 ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(baseDelivery), 'utf8'), + cipher.final(), + ]); + const wrappedKey = crypto.publicEncrypt({ + key: publicKey, + oaepHash: 'sha256', + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + }, contentKey); const segments = [encodedHeader, ...[wrappedKey, iv, ciphertext, cipher.getAuthTag()] - .map(value => value.toString('base64url'))]; + .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); + assert.equal((await invoke(body)).status, 200); 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]); -}); + failure(await invoke({ ...body, encryptedDeliveryContext: segments.join('.') }), + 400, 'decryption_failed'); -test('evaluation accepts a Key Vault PEM certificate bundle in either order and base64 form', async () => { const certificate = require('node:tls').rootCertificates[0]; const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); for (const bundle of [`${certificate}\n${pem}`, `${pem}\n${certificate}`]) { for (const value of [bundle, Buffer.from(bundle).toString('base64')]) { process.env.EPP_DECRYPTION_KEY_PEM = value; - const result = await invoke(await envelope({ mode: 'evaluation' })); - assert.equal(result.status, 200); - assert.equal(result.jsonBody.nonce, delivery.nonce); + assert.equal((await invoke(await envelope({ mode: 2 }))).status, 200); } } - assert.equal(getSecret.mock.callCount(), 0); - assert.equal(fetchMock.mock.callCount(), 0); }); -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(summary().evaluation, true); - assert.equal(summary().result, 'evaluated'); - assert.equal(summary().providerAttempted, false); - assert.equal(summary().providerName, null); - assert.equal(summary().providerHttpStatus, null); - assert.equal(summary().providerElapsedMs, null); - assert.equal(summary().providerCredentialSource, null); - assert.equal(summary().providerCredentialElapsedMs, null); - assert.equal(summary().providerEndpoint, null); - assert.deepEqual(warnings.map((record) => record.eventName), - expectedKeyId === 'private-kid' ? ['encryption_key_id_mismatch'] : []); - assert.equal(summary().encryptionKeyIdMismatch, expectedKeyId === 'private-kid'); - assert.deepEqual(records.filter((record) => record.eventName !== 'encryption_key_id_mismatch') - .map((record) => record.eventName), fixtures.logging.evaluationEvents); - } - assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); - assert.equal(getToken.mock.callCount(), 0); -}); - -test('Soprano OAuth failures never fall back to keys or forward an inbound token', async () => { - for (const failure of [async () => { throw new Error('PRIVATE-TOKEN-ERROR'); }, - async () => ({ token: 'PRIVATE-EXPIRED', expiresOnTimestamp: Date.now() - 1 })]) { - getToken.mock.mockImplementation(failure); - assertFailure(await invoke(await envelope({}, { ...delivery, providerJwt: 'FORGED-PAYLOAD' }), - { authorization: 'Bearer FORGED-INBOUND' }), 502); - assert.equal(summary().failureStage, 'provider_credentials'); - assert.equal(summary().failureReason, 'credential_unavailable'); - assert.equal(summary().providerAttempted, false); - assert.equal(summary().providerCredentialSource, 'managed_identity_client_assertion'); - assert.ok(summary().providerCredentialElapsedMs >= 0); - assert.equal(records.some((record) => record.eventName === 'provider_credential_resolved'), false); - } - assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); +test('evaluation checks advisory key ID but remains provider-independent', async () => { + delete process.env.EPP_PROVIDER_ENDPOINT; + delete process.env.KEY_VAULT_URL; + process.env.EPP_ENCRYPTION_KEY_ID = 'different-kid'; + const result = await invoke(await envelope({ mode: 2 })); + assert.equal(result.status, 200); + assert.ok(event('encryption_key_id_mismatch')); + assert.equal(event('provider_selected'), undefined); + assert.deepEqual([getToken.mock.callCount(), getSecret.mock.callCount(), fetchMock.mock.callCount()], + [0, 0, 0]); }); -test('SMS/voice preserve content and correlation without reflecting headers or logging PII', async () => { - const correlationId = 'support-correlation-id'; - 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' }, - { ...delivery, textToVoice: { language: 'override', gender: 2, loop: 9 } }), 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.messageTypes, sent.correlationId], [[name], correlationId]); - if (channel === 2) { - assert.deepEqual(sent.voice, { text2voice: { - beforePasswordText: ' PRIVATE-MESSAGE ', - password: '918273', - afterPasswordText: '.\n', - language: delivery.locale, - gender: 1, - loop: 2, - } }); - assert.equal(sent.text, undefined); - } else { - assert.equal(sent.text, delivery.message); - assert.equal(sent.voice, undefined); - } - assert.deepEqual(init.headers, { 'Content-Type': 'application/json', Accept: 'application/json', - Authorization: 'Bear' + 'er PRIVATE-OAUTH-TOKEN' }); - assert.equal(init.redirect, 'manual'); - assert.deepEqual(records.map((record) => record.eventName), fixtures.logging.liveEvents); - assert.equal(summary()['x-ms-correlation-id'], correlationId); - assert.equal(summary().providerName, 'soprano'); - assert.equal(summary().providerAuthMode, 'oauth'); - assert.equal(summary().providerMessageId, 'provider-reference-id'); - assert.equal(summary().providerHttpStatus, 201); - assert.equal(summary().providerStatus, 'ENROUTE'); - assert.equal(summary().providerOutcome, 'Continue'); - assert.equal(summary().channel, name); - assert.equal(summary().providerAttempted, true); - assert.equal(summary().providerTimeoutMs, 1500); - assert.ok(summary().providerElapsedMs >= 0 && summary().providerElapsedMs <= summary().elapsedMs); - assert.equal(summary().failureStage, null); - assert.doesNotMatch(JSON.stringify(logs), /PRIVATE|918273|001234|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('live Soprano flow preserves message/correlation and never forwards inbound authorization', async () => { + const result = await invoke(await envelope(), { + authorization: 'FORGED-INBOUND-AUTH', + 'x-ms-client-principal': 'FORGED-PRINCIPAL', + 'x-ms-client-request-id': 'client-request-id', + }); + assert.deepEqual(result, { + status: 200, + jsonBody: { + nonce: baseDelivery.nonce, + correlationId: 'correlation-id', + providerStatus: 'accepted', + }, + }); + const [url, init] = fetchMock.mock.calls[0].arguments; + assert.equal(url, process.env.EPP_PROVIDER_ENDPOINT); + assert.equal(init.redirect, 'manual'); + assert.equal(init.headers.Authorization, 'Bearer PRIVATE-OAUTH-TOKEN'); + assert.notEqual(init.headers.Authorization, 'FORGED-INBOUND-AUTH'); + assert.deepEqual(JSON.parse(init.body), { + destination: '15551234567', + messageTypes: ['sms'], + correlationId: 'correlation-id', + shutterMode: false, + text: baseDelivery.message, + }); + assert.deepEqual(records.map((record) => record.eventName), [ + 'request_received', + 'envelope_validated', + 'delivery_context_decrypted', + 'provider_selected', + 'provider_credential_resolution_started', + 'provider_credential_resolved', + 'provider_request_build_started', + 'provider_request_built', + 'provider_request_started', + 'provider_response_received', + 'provider_response_processed', + 'response_prepared', + 'request_completed', + ]); + assert.equal(event('provider_response_processed').providerMessageId, 'provider-reference-id'); }); -test('Telesign EPP sends decrypted SMS and voice content with Basic auth and private logs', async () => { - process.env.EPP_PROVIDER_NAME = 'telesign'; - process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com/epp/send'; - process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; - for (const [channel, name, code] of [[1, 'sms', 290], [2, 'voice', 100], - [1, 'sms', 3001], [2, 'voice', 3001]]) { - fetchMock.mock.mockImplementation(async () => ({ ok: true, status: 200, - text: async () => JSON.stringify({ reference_id: 'telesign-reference-id', correlation_id: 'provider-correlation', - status: { code, description: 'PRIVATE-STATUS' } }) })); - const result = await invoke(await envelope({ channel }), { authorization: 'Bearer FORGED-TOKEN', 'x-shutter-mode': 'true' }); - assert.deepEqual(result.jsonBody, { nonce: delivery.nonce, correlationId: 'correlation-id', providerStatus: 'accepted' }); - assert.equal(result.status, 200); - const [url, init] = fetchMock.mock.calls.at(-1).arguments; - assert.equal(url, 'https://verify.telesign.com/epp/send'); - const expectedText = name === 'voice' - ? ' PRIVATE-MESSAGE 9, 1, 8, 2, 7, 3.\n PRIVATE-MESSAGE 9, 1, 8, 2, 7, 3.\n' - : delivery.message; - assert.deepEqual(JSON.parse(init.body), { recipient: { phone_number: delivery.phoneNumber }, - message: { text: expectedText, language: delivery.locale }, channels: [{ channel: name }], correlation_id: 'correlation-id' }); - assert.deepEqual(init.headers, { Authorization: `Basic ${Buffer.from('PRIVATE-API-KEY:PRIVATE-API-KEY').toString('base64')}`, - 'Content-Type': 'application/json', Accept: 'application/json' }); - assert.equal(init.redirect, 'manual'); - assert.doesNotMatch(JSON.stringify(logs), /PRIVATE|918273|15551234567|FORGED/); - assert.equal(result.jsonBody.reference_id, undefined); - assert.equal(summary().providerStatus, String(code)); - assert.equal(summary().providerMessageId, 'telesign-reference-id'); - } - assert.equal(fetchMock.mock.callCount(), 4); +test('Soprano voice derives the first rendered six-digit code', async () => { + const result = await invoke(await envelope({ channel: 2 }, { + ...baseDelivery, + message: 'Before 001234 after 654321.', + })); + assert.equal(result.status, 200); + assert.deepEqual(JSON.parse(fetchMock.mock.calls[0].arguments[1].body).voice.text2voice, { + beforePasswordText: 'Before ', + password: '001234', + afterPasswordText: ' after 654321.', + language: 'fr-FR', + gender: 1, + loop: 2, + }); }); -test('Telesign evaluation never sends and invalid recipients never reach HTTP', async () => { - process.env.EPP_PROVIDER_NAME = 'telesign'; - process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com'; - for (const channel of [1, 2]) { - const result = await invoke(await envelope({ channel, mode: 2 })); - assert.equal(result.status, 200); - assert.equal(result.jsonBody.nonce, delivery.nonce); +test('provider selection, configuration, credential and request-build failures are fixed and safe', async () => { + const cases = [ + ['provider_selection', 'unknown_provider', 400, () => { + process.env.EPP_PROVIDER_NAME = 'PRIVATE-UNKNOWN'; + }, {}], + ['provider_configuration', 'channel_not_configured', 400, () => { + process.env.EPP_PROVIDER_CHANNEL = 'voice'; + }, {}], + ['provider_configuration', 'authentication_mode_mismatch', 502, () => { + process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; + }, {}], + ['provider_configuration', 'invalid_provider_endpoint', 502, () => { + process.env.EPP_PROVIDER_ENDPOINT = 'http://PRIVATE-ENDPOINT'; + }, {}], + ['provider_request_build', 'request_build_failed', 502, () => {}, { + delivery: { ...baseDelivery, message: 'No passcode here.' }, + envelope: { channel: 2 }, + }], + ]; + for (const [stage, reason, status, configure, options] of cases) { + configure(); + const result = await invoke(await envelope(options.envelope, options.delivery || baseDelivery)); + failure(result, status); + assert.deepEqual([event('request_failed').failureStage, event('request_failed').failureReason], + [stage, reason]); + Object.assign(process.env, { + EPP_PROVIDER_NAME: 'soprano', + EPP_PROVIDER_ENDPOINT: 'https://provider.example/epp/messages', + EPP_PROVIDER_AUTH_MODE: 'oauth', + }); + delete process.env.EPP_PROVIDER_CHANNEL; } - assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); - assertFailure(await invoke(await envelope({}, { ...delivery, phoneNumber: '15551234567' })), 502); - assert.equal(fetchMock.mock.callCount(), 0); }); -test('Telesign missing status or upstream failure never acknowledges delivery', async () => { - process.env.EPP_PROVIDER_NAME = 'telesign'; - process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com'; - process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; - for (const [status, payload, expected] of [[200, {}, 502], [500, { status: { code: 290 } }, 502], - [429, { status: { code: 290 } }, 429], [500, { status: { code: 3001 } }, 502], - [401, { status: { code: 3001 } }, 401], [429, { status: { code: 3001 } }, 429]]) { - fetchMock.mock.mockImplementation(async () => ({ ok: status === 200, status, text: async () => JSON.stringify(payload) })); - assertFailure(await invoke(await envelope()), expected); - assert.equal(summary().providerHttpStatus, status); - assert.equal(summary().providerOutcome, 'Fail'); - assert.equal(summary().failureStage, 'provider_response'); - assert.equal(summary().failureReason, 'provider_rejected'); - } - assert.equal(fetchMock.mock.callCount(), 6); +test('credential failure emits request_failed without provider HTTP', async () => { + credentialTokenService.getCredentials.mock.mockImplementation(async () => { + throw new Error('PRIVATE-CREDENTIAL-FAILURE'); + }); + failure(await invoke(await envelope()), 502); + assert.deepEqual([ + event('request_failed').failureStage, + event('request_failed').failureReason, + fetchMock.mock.callCount(), + ], ['provider_credentials', 'credential_unavailable', 0]); }); -test('handler awaits the provider body and returns 502/429 without a nonce or retries', async () => { - for (const status of [500, 429]) { - let release; - 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.equal(records.some((record) => record.logType === 'request'), false); - assert.equal(records.at(-1).eventName, 'provider_response_received'); - assert.ok(records.some((record) => record.eventName === 'provider_request_started')); - } finally { - release(JSON.stringify({ status: 'ENROUTE', description: 'PRIVATE-STATUS' })); - } - assertFailure(await pending, status === 500 ? 502 : 429); +test('provider network, timeout, invalid JSON, unknown status and HTTP failures classify safely', async () => { + const cases = [ + [async () => { throw new Error('PRIVATE-NETWORK'); }, 502, 'provider_network_error'], + [async (_url, { signal }) => ({ + ok: true, + status: 200, + text: () => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('PRIVATE-TIMEOUT')), { once: true }); + }), + }), 504, 'provider_timeout'], + [async () => ({ ok: true, status: 200, text: async () => '' }), + 502, 'invalid_provider_json'], + [async () => ({ ok: true, status: 200, + text: async () => JSON.stringify({ id: 'provider-id', status: 'PRIVATE-STATUS' }) }), + 502, 'unrecognized_provider_status'], + [async () => ({ ok: false, status: 429, + text: async () => JSON.stringify({ id: 'provider-id', status: 'ENROUTE' }) }), + 429, 'provider_http_error'], + ]; + for (const [implementation, status, reason] of cases) { + process.env.EPP_PROVIDER_TIMEOUT_MS = status === 504 ? '1' : '1500'; + fetchMock.mock.mockImplementation(implementation); + failure(await invoke(await envelope()), status); + assert.equal(event('request_failed').failureReason, reason); + assert.equal(records.at(-1).eventName, 'request_completed'); } - assert.equal(fetchMock.mock.callCount(), 2); }); -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 }); - }), +test('Sinch successful HTTP without a nonblank message ID fails closed', async () => { + process.env.EPP_PROVIDER_NAME = 'sinch'; + process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; + process.env.SINCH_SERVICE_PLAN_ID = 'plan'; + fetchMock.mock.mockImplementation(async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ id: ' ', status: 'Delivered' }), })); - assertFailure(await invoke(await envelope()), 504); - assert.equal(fetchMock.mock.callCount(), 1); - assert.equal(fetchMock.mock.calls[0].arguments[1].signal.aborted, true); - assert.equal(summary().failureStage, 'provider_transport'); - assert.equal(summary().failureReason, 'provider_timeout'); - assert.equal(summary().providerHttpStatus, 200); - assert.equal(summary().providerTimeoutMs, 1); - assert.equal(summary().providerStatus, null); - assert.ok(summary().providerElapsedMs >= 0); + failure(await invoke(await envelope()), 502); + assert.equal(event('request_failed').failureReason, 'missing_provider_message_id'); }); -test('Microsoft, function and provider identifiers have distinct sources and no synthetic Microsoft IDs', async () => { - const headers = { 'x-ms-client-request-id': 'ms-request-id', 'x-ms-correlation-id': 'ms-header-correlation-id' }; - for (const correlationId of ['ms-envelope-correlation-id', null]) { - await invoke(await envelope({ correlationId }), headers); - assert.equal(summary()['x-ms-client-request-id'], headers['x-ms-client-request-id']); - assert.equal(summary()['x-ms-correlation-id'], correlationId || headers['x-ms-correlation-id']); - assert.equal(summary().msCorrelationIdSource, correlationId ? 'envelope' : 'header'); - assert.equal(records[0].msCorrelationIdSource, 'header'); - assert.notEqual(summary().functionRequestId, summary()['x-ms-client-request-id']); - assert.notEqual(summary().functionRequestId, summary().functionInvocationId); +test('Telesign strict integer status and voice pacing keep endpoint outcomes stable', async () => { + process.env.EPP_PROVIDER_NAME = 'telesign'; + process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; + process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com/epp/send'; + for (const [code, status] of [[290, 200], ['290', 502], [true, 502]]) { + fetchMock.mock.mockImplementation(async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ reference_id: 'reference-id', status: { code } }), + })); + const result = await invoke(await envelope({ channel: 2 })); + assert.equal(result.status, status); + const sent = JSON.parse(fetchMock.mock.calls.at(-1).arguments[1].body); + assert.equal(sent.message.text, + ' PRIVATE-MESSAGE 9, 1, 8, 2, 7, 3.\n PRIVATE-MESSAGE 9, 1, 8, 2, 7, 3.\n'); } - const result = await invoke(await envelope({ correlationId: null })); - assert.equal(result.jsonBody.correlationId, summary().functionRequestId); - assert.equal(summary()['x-ms-client-request-id'], null); - assert.equal(summary()['x-ms-correlation-id'], null); - assert.equal(summary().msCorrelationIdSource, 'none'); }); -test('early failures keep incoming Microsoft trace IDs and a fixed failure stage', async () => { - const headers = { 'x-ms-client-request-id': 'ms-request-id', 'x-ms-correlation-id': 'ms-header-correlation-id' }; - for (const [body, stage, reason] of [ - ['{', 'request_validation', 'invalid JSON body'], - [{}, 'request_validation', 'unsupported envelope type'], - [await envelope({ encryptedDeliveryContext: 'PRIVATE-NOT-A-JWE' }), 'decryption', 'decryption_failed'], - [await envelope({}, { ...delivery, nonce: '' }), 'delivery_context_validation', 'incomplete delivery context'], +test('correlation precedence preserves wire IDs while logs omit unsafe values', async () => { + const headers = { + 'x-ms-client-request-id': 'client-request-id', + 'x-ms-correlation-id': 'header-correlation-id', + }; + for (const [value, expected] of [ + ['envelope-correlation-id', 'envelope-correlation-id'], + ['opaque correlation/value', 'opaque correlation/value'], + [null, 'header-correlation-id'], + [{ private: 'PRIVATE' }, 'header-correlation-id'], ]) { - const result = await invoke(body, headers); - assert.equal(result.status, 400); - assert.equal(summary().functionRequestId, result.jsonBody.requestId); - assert.equal(summary()['x-ms-client-request-id'], headers['x-ms-client-request-id']); - assert.equal(summary().failureStage, stage); - assert.equal(summary().msCorrelationIdSource, stage === 'request_validation' ? 'header' : 'envelope'); - assert.equal(summary()['x-ms-correlation-id'], - stage === 'request_validation' ? headers['x-ms-correlation-id'] : 'correlation-id'); - assert.equal(summary().failureReason, reason); - assert.equal(summary().providerAttempted, false); - assert.equal(records.at(-3).eventName, `${stage}_failed`); - } -}); - -test('invalid correlation metadata cannot become a raw log field or prevent the request summary', async () => { - for (const correlationId of [42, { detail: 'support-correlation-id' }, ['support-correlation-id'], '']) { - const result = await invoke(await envelope({ mode: 2, correlationId })); - assert.equal(result.status, 200); - assert.equal(summary()['x-ms-correlation-id'], null); - assert.equal(summary().msCorrelationIdSource, 'none'); - } -}); - -for (const [name, configure, stage, reason, status] of [ - ['unknown provider', () => { process.env.EPP_PROVIDER_NAME = 'PRIVATE-UNKNOWN-PROVIDER'; }, - 'provider_selection', 'unknown_provider', 400], - ['wrong channel', () => { process.env.EPP_PROVIDER_CHANNEL = 'voice'; }, - 'provider_configuration', 'channel_not_configured', 400], - ['authentication mismatch', () => { process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; }, - 'provider_configuration', 'authentication_mode_mismatch', 502], - ['invalid endpoint', () => { process.env.EPP_PROVIDER_ENDPOINT = 'http://PRIVATE-ENDPOINT'; }, - 'provider_configuration', 'invalid_provider_endpoint', 502], - ['request build failure', () => { - mock.method(getProvider('soprano').adapter, 'buildRequest', () => { throw new Error('PRIVATE-BUILD-ERROR'); }); - }, 'provider_request_build', 'request_build_failed', 502], - ['network failure', () => { - fetchMock.mock.mockImplementation(async () => { throw new Error('PRIVATE-NETWORK-ERROR'); }); - }, 'provider_transport', 'provider_network_error', 502], - ['adapter response failure', () => { - mock.method(getProvider('soprano').adapter, 'parseResponse', () => { throw new Error('PRIVATE-PARSE-ERROR'); }); - }, 'provider_response', 'response_parse_failed', 500], -]) { - test(`${name} emits its own service failure and a complete request summary`, async () => { - configure(); - assertFailure(await invoke(await envelope()), status); - assert.equal(summary().failureStage, stage); - assert.equal(summary().failureReason, reason); - assert.equal(records.at(-3).eventName, `${stage}_failed`); - const attempted = ['provider_transport', 'provider_response'].includes(stage); - assert.equal(summary().providerAttempted, attempted); - assert.equal(fetchMock.mock.callCount(), attempted ? 1 : 0); - }); -} - -test('unknown provider status and malformed provider JSON never become raw diagnostic fields', async () => { - for (const body of [JSON.stringify({ id: 'provider-reference-id', status: 'PRIVATE-STATUS\nFORGED', description: 'PRIVATE-DESCRIPTION' }), - 'PRIVATE-RESPONSE']) { - fetchMock.mock.mockImplementation(async () => ({ ok: true, status: 200, text: async () => body })); - assertFailure(await invoke(await envelope()), 502); - assert.equal(summary().providerStatus, 'unmapped'); - assert.equal(summary().providerOutcome, 'Fail'); - assert.equal(summary().failureReason, body.startsWith('{') ? 'provider_rejected' : 'invalid_provider_json'); - } -}); - -test('interleaved invocations retain their own log context and emit service events before completion', async () => { - const eventSets = [[], []]; - const invocations = ['function-one', 'function-two']; - const contexts = eventSets.map((events, index) => ({ - invocationId: invocations[index], - log: (value) => events.push(JSON.parse(value)), - warn: (value) => events.push(JSON.parse(value)), - error: (value) => events.push(JSON.parse(value)), - })); - const bodies = await Promise.all(['correlation-first', 'correlation-second'].map((correlationId) => envelope({ correlationId }))); - await Promise.all(bodies.map((body, index) => handler({ - headers: { get: () => null }, - text: async () => JSON.stringify(body), - }, contexts[index]))); - for (const [index, events] of eventSets.entries()) { - assert.deepEqual(events.map((event) => event.eventName), fixtures.logging.liveEvents); - const summary = events.at(-1); - assert.equal(summary['x-ms-correlation-id'], bodies[index].correlationId); - assert.ok(events.every((event) => event.functionRequestId === summary.functionRequestId - && event.functionInvocationId === invocations[index])); + const result = await invoke(await envelope({ mode: 2, correlationId: value }), headers); + assert.equal(result.jsonBody.correlationId, expected); + assert.equal(records[0]['x-ms-correlation-id'], 'header-correlation-id'); + assert.equal(records.at(-1)['x-ms-correlation-id'], + value === 'opaque correlation/value' ? null : expected); + assert.equal(records.at(-1).omittedIdFields.includes('x-ms-correlation-id'), + value === 'opaque correlation/value'); } - assert.notEqual(eventSets[0].at(-1).functionRequestId, eventSets[1].at(-1).functionRequestId); - assert.doesNotMatch(JSON.stringify(eventSets), /PRIVATE/); + const result = await invoke(await envelope({ mode: 2, correlationId: null })); + assert.equal(result.jsonBody.correlationId, records.at(-1).functionRequestId); }); -test('successful lifecycle logs allowed body fields, raw OAuth IDs and an endpoint without its query', async () => { +test('fixed logging excludes bodies, secrets, query strings, descriptions and exceptions', async () => { process.env.EPP_PROVIDER_ENDPOINT = 'https://provider.example/api/send?key=PRIVATE-QUERY'; - const body = await envelope({ + fetchMock.mock.mockImplementation(async () => ({ + ok: true, + status: 201, + text: async () => JSON.stringify({ + id: 'support-id', + status: 'ENROUTE', + description: 'PRIVATE-DESCRIPTION', + }), + })); + await invoke(await envelope({ tenantId: 'PRIVATE-TENANT', - diagnosticData: { token: 'PRIVATE-UNKNOWN-FIELD' }, + unknown: { body: 'PRIVATE-BODY' }, + }), { authorization: 'PRIVATE-INBOUND' }); + assert.equal(event('provider_request_built').providerEndpoint, + 'https://provider.example/api/send'); + assert.equal(event('provider_request_started').providerEndpoint, + 'https://provider.example/api/send'); + assert.equal(event('provider_request_started').providerHttpMethod, 'POST'); + assert.deepEqual({ + providerTenantId: event('provider_credential_resolution_started').providerTenantId, + functionOutboundClientId: + event('provider_credential_resolution_started').functionOutboundClientId, + functionOutboundManagedIdentityClientId: + event('provider_credential_resolution_started').functionOutboundManagedIdentityClientId, + }, { + providerTenantId: process.env.EPP_PROVIDER_TENANT_ID, + functionOutboundClientId: process.env.EPP_OUTBOUND_CLIENT_ID, + functionOutboundManagedIdentityClientId: process.env.EPP_OUTBOUND_MI_CLIENT_ID, }); - await invoke(body, { 'x-ms-client-principal': 'PRIVATE-PRINCIPAL', authorization: 'PRIVATE-INBOUND-AUTH' }); - const validated = records.find((record) => record.eventName === 'envelope_validated'); - assert.equal(validated.envelopeType, body.type); - assert.equal(validated.ttlSeconds, 60); - assert.equal(validated.encryptedDeliveryContextPresent, true); - assert.equal(summary().envelopeType, body.type); - assert.equal(summary().ttlSeconds, 60); - const source = 'managed_identity_client_assertion'; - for (const record of [summary(), ...records.filter((record) => - ['provider_credential_resolution_started', 'provider_credential_resolved'].includes(record.eventName))]) { - assert.equal(record.providerCredentialSource, source); - assert.equal(record.functionOutboundClientId, process.env.EPP_OUTBOUND_CLIENT_ID); - assert.equal(record.functionOutboundManagedIdentityClientId, process.env.EPP_OUTBOUND_MI_CLIENT_ID); - assert.equal(record.providerTenantId, process.env.EPP_PROVIDER_TENANT_ID); - } - assert.ok(summary().providerCredentialElapsedMs >= 0 && summary().providerCredentialElapsedMs <= summary().elapsedMs); - for (const record of [summary(), ...records.filter((record) => - ['provider_request_built', 'provider_request_started'].includes(record.eventName))]) { - assert.equal(record.providerHttpMethod, 'POST'); - assert.equal(record.providerEndpoint, 'https://provider.example/api/send'); - } - const built = records.find((record) => record.eventName === 'provider_request_built'); - assert.equal(built.providerScheme, 'https'); - assert.equal(built.redirectsAllowed, false); - const output = JSON.stringify(records); - for (const value of [body.encryptedDeliveryContext, process.env.EPP_PROVIDER_ENDPOINT]) { - assert.equal(output.includes(value), false); - } - assert.deepEqual(records.map((record) => record.eventName), fixtures.logging.liveEvents); -}); - -test('API-key resolution is identified as Key Vault even when a later request uses cached credentials', async () => { - process.env.EPP_PROVIDER_NAME = 'telesign'; - process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; - process.env.KEY_VAULT_URL = 'https://logging-cache-test.vault.azure.net'; - fetchMock.mock.mockImplementation(async () => ({ ok: true, status: 200, - text: async () => JSON.stringify({ status: { code: 3001 } }) })); - for (let attempt = 0; attempt < 2; attempt++) { - await invoke(await envelope()); - assert.equal(summary().providerCredentialSource, 'key_vault'); - assert.equal(summary().providerAuthMode, 'apiKey'); - assert.equal(summary().providerTenantId, null); - assert.equal(summary().functionOutboundClientId, null); - assert.equal(summary().functionOutboundManagedIdentityClientId, null); - assert.ok(summary().providerCredentialElapsedMs >= 0); - assert.deepEqual(records.map((record) => record.eventName), fixtures.logging.liveEvents); - assert.equal(getSecret.mock.callCount(), 2); - } - assert.equal(getToken.mock.callCount(), 0); -}); - -test('optional TTL stays null and invalid body values never enter body metadata', async () => { - const body = await envelope({ mode: 2 }); - delete body.ttlSeconds; - assert.equal((await invoke(body)).status, 200); - assert.equal(summary().ttlSeconds, null); - assert.equal(records.find((record) => record.eventName === 'envelope_validated').ttlSeconds, null); - assert.equal((await invoke({ ...body, ttlSeconds: 'PRIVATE-INVALID-TTL' })).status, 400); - assert.equal(summary().envelopeType, null); - assert.equal(summary().ttlSeconds, null); - assert.equal(records.some((record) => record.eventName === 'envelope_validated'), false); -}); - -test('request preparation records the adapter final URL, not the configured base or an arbitrary HTTP verb', async () => { - const adapter = getProvider('soprano').adapter; - const buildRequest = adapter.buildRequest; - const finalUrl = 'https://different-provider.example/api/final?token=PRIVATE-TOKEN'; - mock.method(adapter, 'buildRequest', (options) => ({ - ...buildRequest(options), url: finalUrl, method: 'PRIVATE-METHOD', - })); - await invoke(await envelope()); - assert.equal(summary().providerEndpoint, 'https://different-provider.example/api/final'); - assert.equal(summary().providerHttpMethod, 'other'); - assert.notEqual(summary().providerEndpoint, process.env.EPP_PROVIDER_ENDPOINT); - assert.equal(fetchMock.mock.calls[0].arguments[0], finalUrl); -}); - -test('shared ID cases preserve raw support values or explicitly omit invalid metadata', () => { - const fields = ['x-ms-client-request-id', 'x-ms-correlation-id', 'providerTenantId', - 'functionOutboundClientId', 'functionOutboundManagedIdentityClientId', 'providerMessageId']; - const manifest = getProvider('soprano').manifest; - for (const fixture of fixtures.logging.identifiers) { - const value = fixture.length ? 'A'.repeat(fixture.length) : fixture.value; - const events = []; - const log = new RequestLog({ log: (record) => events.push(JSON.parse(record)) }, 'function-request', value, value); - log.providerSelected(manifest); - log.credentialResolutionStarted({ providerTenantId: value, outboundClientId: value, outboundManagedIdentityClientId: value }); - log.providerResponseProcessed(manifest, { providerStatusName: 'ENROUTE', providerMessageId: value }, 'Continue', 200, true); - log.complete(200); - const summary = events.at(-1); - for (const field of fields) assert.equal(summary[field], fixture.accepted ? value : null); - assert.deepEqual(summary.omittedIdFields, fixture.omitted ? fields : []); - assert.equal(events.some((event) => Object.keys(event).some((key) => key.endsWith('Hash'))), false); - assert.doesNotMatch(JSON.stringify(events), /PRIVATE/); - } -}); - -test('shared endpoint cases retain scheme host port and API path without credentials query or fragment', () => { - for (const fixture of fixtures.logging.endpoints) { - const events = []; - const log = new RequestLog({ log: (record) => events.push(JSON.parse(record)) }, 'function-request', null, null); - log.providerRequestBuilt('POST', fixture.url); - log.providerRequestStarted(1500); - log.complete(200); - assert.ok(events.every((event) => event.providerEndpoint === fixture.logged)); - assert.doesNotMatch(JSON.stringify(events), /PRIVATE/); - } + assert.equal(event('provider_response_processed').providerMessageId, 'support-id'); + assert.equal(event('request_failed'), undefined); + assert.doesNotMatch(JSON.stringify(records), /PRIVATE|918273|15551234567/); });