diff --git a/README.md b/README.md index a0e0b8e..3a5cfbc 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,60 @@ Request `tenantId`, `channel`, `mode` and `ttlSeconds` are request data, not ext The trusted tenant issuer, endpoint-app audience and authorized SAS caller are configured in Easy Auth, not in application environment settings or incoming request data. +## Configure environment variables + +Use the [sample settings](docs/local.settings.sample.json) as the starting point for the chosen +runtime. All entries in its `Values` object are **strings**. The application reads environment +variables; Azure Functions Core Tools loads that `Values` object for local runs. + +The sample uses `node`; change it to `python` or `dotnet-isolated` for those runtimes. Replace the +provider, endpoint, vault and test-key placeholders before use. Its storage value assumes **Azurite +is running**; do not copy `UseDevelopmentStorage=true` into Azure. Optional settings stay in the table +below rather than appearing as required placeholders in the sample. Keep explanatory comments outside +`Values`, otherwise the host loads them as environment variables too. + +The local settings file is an environment-variable input for the Functions host, **not a serialized +`AppConfig` or request model**. For example, `EPP_PROVIDER_NAME` becomes `config.providerName` in +JavaScript, `config.provider_name` in Python, and `config.ProviderName` in .NET. The refactor changed +how code accesses configuration, not the environment-variable names. + +| Variable | When needed | Value | +|---|---|---| +| `AzureWebJobsStorage` | Functions host storage | Local sample: `UseDevelopmentStorage=true` with Azurite running. Configure Azure host storage separately for the selected plan. | +| `FUNCTIONS_WORKER_RUNTIME` | Functions host | `node`, `python`, or `dotnet-isolated`—exactly one value matching the chosen implementation. | +| `EPP_DECRYPTION_KEY_PEM` | Every request | Local test PEM or base64 PEM. In Azure, use a Key Vault reference resolving to the private-key secret. | +| `EPP_ENCRYPTION_KEY_ID` | Optional | Expected encryption key ID; mismatch only produces an advisory warning. | +| `EPP_PROVIDER_NAME` | Live delivery | Selected adapter's manifest ID. No default provider. | +| `EPP_PROVIDER_ENDPOINT` | Live delivery | HTTPS **base URL**, in the same environment as the provider credentials; the adapter adds its route. | +| `EPP_PROVIDER_TIMEOUT_MS` | Optional | Decimal milliseconds. Defaults to `1500`, capped at `2500`; not an end-to-end deadline. | +| `EPP_PROVIDER_ACCOUNT_NAME` | Adapter-dependent | Sender/account metadata, not an API key or credential identity. | +| `KEY_VAULT_URL` | Provider credential lookup | URI of the vault containing the manifest-named provider secrets. Separate from the encryption-key reference. | +| `AZURE_CLIENT_ID` | Optional | User-assigned managed identity's client ID for Key Vault. Leave unset for system-assigned identity. | + +1. **Locally:** create private local settings beside the chosen runtime's host file, following its + [JavaScript](javascript/README.md#environment-configuration), [Python](python/README.md#environment-configuration) + or [.NET](dotnet/README.md#environment-configuration) instructions. Restart the host after edits. +2. **In Azure:** set the same application variables on the selected Function App (or serving slot) + under **Settings → Environment variables → App settings**, then apply the changes. Local settings + are not published automatically. Configure host storage separately for the selected hosting plan. +3. Store provider API keys and any required identity secrets in Key Vault using the **exact names in + the adapter manifest**. Grant that app/slot's managed identity *Key Vault Secrets User* on those + secrets. An API key in a local environment variable is not a supported replacement for the resolver. + +Evaluation requests do not need provider variables or provider secrets. They still need the decryption +key. The default credential resolvers use `ManagedIdentityCredential`, **not** the developer's CLI +login; ordinary local machines have no managed-identity endpoint. Use offline tests or loopback-only +evaluation locally, or an explicitly injected test resolver for integration work. Never commit local +settings, keys or test credentials. + +Core Tools does not resolve Azure Key Vault reference expressions locally. Supply the local test PEM +or base64 PEM directly; use a reference such as `@Microsoft.KeyVault(SecretUri=https://.vault.azure.net/secrets//)` +for `EPP_DECRYPTION_KEY_PEM` in Azure app settings, where the platform resolves it. + +Configure inbound issuer/audience/caller trust in **Easy Auth**, not these application variables. +Incoming `tenantId`, `channel`, `mode` and `ttlSeconds` are request data. No outbound OAuth settings +are supported by this main-based implementation. + ## Security **Easy Auth (App Service Authentication) is the only caller-authentication gate, before the anonymous @@ -69,3 +123,11 @@ authentication; [separate deployed security checks](docs/ONBOARDING.md#4-package `parseResponse` — no engine changes. See the language folder's README. - **New language**: mirror the folder structure, implement the contract, add the same test scenarios, and wire it into [.github/workflows/ci.yml](.github/workflows/ci.yml). + +### Future pull requests + +Start a short-lived branch from up-to-date `main`. After review and passing checks, select **Squash +and merge** to place one commit on `main`, then delete that PR's feature branch. Squashing is a merge +choice, not automatic just because commits are on a feature branch. Do not merge old feature histories +into a new branch or delete other branches containing unmerged work. This workflow does not rewrite +existing `main` history. diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 7fe7d82..01ab72d 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -3,9 +3,9 @@ This defines the shared contract for [JavaScript](../javascript/), [Python](../python/) and [.NET](../dotnet/). See [production limitations](#production-limitations) before production use. -> **Naming.** "CYOT" (Choose Your Own Telecom) is the internal code name for this feature. It still -> appears in wire-level identifiers that must not change — type names (`SendCyotOtpRequest`, -> `CyotDeliveryContext`) and the caller's `User-Agent`. App settings use the `EPP_` prefix. +> **Naming.** EPP means **External Phone Provider**. App settings use the `EPP_` prefix; the +> request and delivery models are `Envelope`, `DeliveryContext` and `DispatchRequest`. +> Documentation names do not change the external JSON fields or `microsoft.mfa.otpDeliver.v1` version. 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. @@ -26,7 +26,7 @@ the cleartext envelope carries routing/scheduling only. | Header | Notes | |--------|-------| | `Authorization` | consumed by platform authentication, not parsed or echoed by handler | -| `User-Agent` | e.g. `Microsoft-AzureMFA-SAS-CYOT/1.0`; not logged | +| `User-Agent` | caller-supplied identifier; not interpreted or logged | | `x-ms-correlation-id` | tracing only; fallback for envelope `correlationId`, not authentication | | `x-ms-client-request-id` | per-attempt tracing id (used as `messageId`), not authentication | @@ -34,7 +34,7 @@ Forwarded headers, including `x-ms-client-principal`, do not establish trust by replace the required Easy Auth gate. The handler does not use them to authenticate callers or forward the incoming `Authorization` header to the provider. Configure Easy Auth as described in section 5. -### Request body — `SendCyotOtpRequest` (cleartext envelope) +### EPP request body — `Envelope` (cleartext envelope) | Field | Required | Notes | |-------|----------|-------| @@ -46,6 +46,10 @@ the incoming `Authorization` header to the provider. Configure Easy Auth as desc | `ttlSeconds` | no | positive JSON integer, at most `2147483647`; null, booleans, strings, fractions and nonpositive values are rejected. Use canonical integer notation (`60`, not `60.0` or `6e1`) across runtimes | | `encryptedDeliveryContext` | yes | JWE compact serialization (see below) | +Canonical integer notation is a caller requirement, not a portable raw-JSON-token check: JavaScript's +JSON parser normalizes `60.0` and `6e1` to `60`, while Python/.NET reject those representations here. +Always send `60` to obtain the same result across runtimes; no custom JSON tokenizer is used. + Unknown `type`, invalid `ttlSeconds`, unsupported `channel` or `mode`, or missing/empty `encryptedDeliveryContext` → `400`. Arrays, objects and booleans are not channel/mode values. These are request data, not settings to provision. The TTL check validates the supplied value; it @@ -58,7 +62,7 @@ Alg: **RSA-OAEP-256** (CEK wrap) + **A256GCM** (content). The JOSE protected hea this sample uses the single configured RSA private key (`EPP_DECRYPTION_KEY_PEM`, a Key Vault reference in Azure), not a multi-key lookup. The compact JWE must have **exactly five non-empty segments** and at most **16,384 characters**; `alg`/`enc` are pinned (only `RSA-OAEP-256` + `A256GCM` accepted) and the AES-GCM auth tag is -verified before any plaintext is used. Decrypted plaintext = `CyotDeliveryContext`: +verified before any plaintext is used. Decrypted plaintext = `DeliveryContext`: The original compact JWE is passed unchanged to the JOSE library. Parsing header fields for the advisory key-ID check must not replace the original protected-header bytes used for authentication. @@ -83,7 +87,7 @@ JWE provides payload confidentiality and integrity, **not SAS caller authenticat public key can encrypt a request. The nonce acknowledges decryption; it is not an authentication credential or replay protection, and a fixed nonce cannot substitute for Easy Auth. -### Response — `CyotEndpointResponse` (JSON) +### EPP response (JSON) ```json { "nonce": "", "correlationId": "", "providerStatus": "accepted" } @@ -151,8 +155,22 @@ Each provider is one unit exposing three things: - `auth` — `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }`; other modes fail closed - `responseMapping` — map of provider status → `Continue` | `Fail` | `Block` | `StepUp` (+ `default`) - **`buildRequest({ channel, endpoint, dispatch, credential, env })`** → `{ url, method, headers, body }` -- **`parseResponse({ httpStatus, ok, json })`** → `{ success, providerHttpStatus, providerMessageId, - providerStatusName | providerStatusCode, providerStatusDescription }` +- **`parseResponse({ httpStatus, ok, json })`** → `ParsedResponse`, containing `success`, + `providerHttpStatus`, optional `providerMessageId`, `providerStatusName`, `providerStatusCode` + and `providerStatusDescription` (snake_case attributes in Python, PascalCase in .NET). + +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. + +This model is internal: do not serialize it into the endpoint response or log its fields. 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 +class hierarchy is required. Adapters require registration in the chosen runtime. Consult the selected adapter and its manifest for required credentials and options: the manifest declares secret names and protocol mappings; @@ -194,9 +212,15 @@ there is no implicit default or automatic failover. Request-body provider fields The shared configuration readers are [JavaScript `readConfig`](../javascript/src/functions/config.js), [Python `read_config`](../python/src/config.py), and [.NET `AppConfig.Read`](../dotnet/Src/AppConfig.cs). -They expose encryption, Key Vault and selected-provider settings, not caller-authentication settings. +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. +JSON parsing and type checks stay at the request boundary. Downstream code uses `Envelope`, +`DeliveryContext` and `DispatchRequest` models (documented object shapes in JavaScript, 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, diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index d91f705..bbb14e1 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -74,14 +74,28 @@ script prerequisites separately; the application tests do not validate provision ## 2. Provision encryption and deployment trust Use [local.settings.sample.json](local.settings.sample.json) as a starting point, replacing its -placeholders with the selected adapter's configuration. Keep local settings private and set the -same shared values in the Function App environment for deployment; the +placeholders with the selected adapter's configuration and choosing the matching worker runtime. +The sample's `UseDevelopmentStorage=true` is local-only and requires Azurite. Keep local settings +private; set application values in the Function App environment for deployment, configure its host +storage separately, and use a Key Vault reference instead of a local private-key value. The [configuration catalog](CONTRACT.md#4-configuration-app-settings--env) is authoritative. - Configure `EPP_DECRYPTION_KEY_PEM` through a Key Vault secret reference in Azure and give the caller the matching public key. `EPP_ENCRYPTION_KEY_ID` is an optional advisory comparison after decryption, not strict key pinning or multi-key lookup. +**Do not enable Entra access-token encryption for the Easy Auth resource app.** Leave its app +registration's `tokenEncryptionKeyId` as `null`; if previously configured, clear that property without +deleting its certificates or changing signing keys. This integration expects a signed bearer JWT, +not an encrypted access token that requires a separate private-key decryption step before validation. +After changing the registration, request a fresh token rather than reusing a cached encrypted token. +The resource is the endpoint app configured in Easy Auth's `clientId`/audience, not necessarily the +application requesting the token. The application code does not configure `tokenEncryptionKeyId`. + +This is separate from the **required JWE encryption of `encryptedDeliveryContext`** in the request +body. Keep `EPP_DECRYPTION_KEY_PEM`; `EPP_ENCRYPTION_KEY_ID` only produces an advisory warning after +successful payload decryption and cannot cause a platform `401`. + Configure caller trust in the Function App's **App Service Authentication (Easy Auth)** platform settings, not application environment variables: diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index 2641b20..6071ed5 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -1,16 +1,14 @@ { - "_comment": "Choose one runtime and a registered adapter. Credential values stay in Key Vault. Azure requires Easy Auth with authentication required and an allowed caller; configure it separately. Local hosts are unauthenticated and must stay on loopback.", + "_comment": "Local template: copy beside the chosen app's host.json and replace placeholders. Change node to python or dotnet-isolated for those runtimes. Start Azurite for UseDevelopmentStorage=true. Provider credentials stay in Key Vault. Local hosts have no Easy Auth; keep them on loopback. See README.md for Azure settings and optional values.", "IsEncrypted": false, "Values": { - "FUNCTIONS_WORKER_RUNTIME": "node | dotnet-isolated | python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "node", - "EPP_DECRYPTION_KEY_PEM": "", - "EPP_ENCRYPTION_KEY_ID": "", + "EPP_DECRYPTION_KEY_PEM": "", "EPP_PROVIDER_NAME": "", "EPP_PROVIDER_ENDPOINT": "https:///", - "EPP_PROVIDER_ACCOUNT_NAME": "", - "_comment_provider_timeout": "Outbound provider HTTP timeout: ASCII decimal milliseconds, default 1500, cap 2500. Not a total invocation deadline; see CONTRACT.md. Provider URLs must be HTTPS; redirects are not followed.", "EPP_PROVIDER_TIMEOUT_MS": "1500", "KEY_VAULT_URL": "https://.vault.azure.net/" diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index 83d14f4..735a639 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Security.Cryptography; using System.Text; -using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; @@ -48,20 +47,9 @@ ObjectResult Reply(int status, object body) var clientRequestId = req.Headers["x-ms-client-request-id"].FirstOrDefault() ?? requestId; correlationId = req.Headers["x-ms-correlation-id"].FirstOrDefault() ?? requestId; - JsonElement payload; - try - { - using var doc = await JsonDocument.ParseAsync(req.Body); - payload = doc.RootElement.Clone(); - } - catch - { - return Reply(400, new { error = "bad_request", reason = "invalid JSON body", requestId }); - } - - var (envelope, envelopeError) = EnvelopeParser.Parse(payload); + var (envelope, envelopeError) = await EnvelopeParser.ParseAsync(req.Body, req.HttpContext.RequestAborted); if (envelopeError is not null) - return Reply(400, new { error = "bad_request", reason = envelopeError, requestId }); + return Reply(400, new EndpointErrorResponse("bad_request", requestId, Reason: envelopeError)); correlationId = envelope!.CorrelationId ?? correlationId; evaluation = envelope.Mode == EnvelopeParser.ModeEvaluation; @@ -73,7 +61,7 @@ ObjectResult Reply(int status, object body) } catch { - return Reply(400, new { error = "decryption_failed", correlationId, requestId }); + return Reply(400, new EndpointErrorResponse("decryption_failed", requestId, CorrelationId: correlationId)); } if (!string.IsNullOrEmpty(config.ExpectedKeyId) @@ -81,12 +69,12 @@ ObjectResult Reply(int status, object body) _log.LogWarning("encryption_key_id_mismatch"); var context = decrypted.Context; - if (string.IsNullOrWhiteSpace(context.Nonce) || string.IsNullOrWhiteSpace(context.PhoneNumber) || string.IsNullOrWhiteSpace(context.Message)) - return Reply(400, new { error = "bad_request", reason = "incomplete delivery context", correlationId, requestId }); + if (!context.IsComplete) + return Reply(400, new EndpointErrorResponse("bad_request", requestId, Reason: "incomplete delivery context", CorrelationId: correlationId)); // Evaluation proves validation/decryption without requiring any provider configuration. if (evaluation) - return Reply(200, new { nonce = context.Nonce, correlationId, providerStatus = "accepted" }); + return Reply(200, new EndpointSuccessResponse(context.Nonce!, correlationId)); var channel = EnvelopeParser.ChannelName(envelope.Channel)!; @@ -101,13 +89,13 @@ ObjectResult Reply(int status, object body) // A nonce acknowledges delivery, not just decryption. Wait for the bounded provider call. var result = await _engine.DispatchAsync(dispatch, requestId); if (result.HttpStatus != 200) - return Reply(result.HttpStatus, new { error = "provider_delivery_failed", correlationId, requestId }); + return Reply(result.HttpStatus, new EndpointErrorResponse("provider_delivery_failed", requestId, CorrelationId: correlationId)); - return Reply(200, new { nonce = context.Nonce, correlationId, providerStatus = "accepted" }); + return Reply(200, new EndpointSuccessResponse(context.Nonce!, correlationId)); } catch { - return Reply(500, new { error = "delivery_failed", correlationId, requestId }); + return Reply(500, new EndpointErrorResponse("delivery_failed", requestId, CorrelationId: correlationId)); } finally { diff --git a/dotnet/README.md b/dotnet/README.md index 1ac8039..2a45212 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -28,6 +28,41 @@ provider per deployment. Target: .NET 8 isolated worker, Azure Functions v4. Offline tests cover application behavior, not platform authentication; run the separate [deployed security checks](../docs/ONBOARDING.md#4-package-deploy-and-verify). +## Environment configuration + +Run Core Tools from `dotnet/`. Create an untracked `local.settings.json` beside +[host.json](host.json), starting from the [shared sample](../docs/local.settings.sample.json). +For local evaluation, start Azurite and replace the test-key placeholder in this minimal setup: + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "EPP_DECRYPTION_KEY_PEM": "" + } +} +``` + +For live delivery, add `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and `KEY_VAULT_URL` to `Values`. +Add `EPP_PROVIDER_ACCOUNT_NAME` and adapter-specific options only when required. Optional +`EPP_PROVIDER_TIMEOUT_MS` is a string such as `"1500"`. Replace placeholders; store provider credentials +under the adapter manifest's Key Vault secret names, not in local settings. See the +[complete variable table](../README.md#configure-environment-variables). + +Core Tools loads `Values` into environment variables. [AppConfig.Read](Src/AppConfig.cs) reads them +through `IEnv`; direct worker execution and unit tests do not automatically load local settings. +Restart the host after edits. Configure local host storage other than Azurite separately; do not copy +the emulator connection into Azure. Core Tools does not resolve Key Vault references locally; supply +the local test PEM or base64 PEM directly. The [project](dotnet.csproj) excludes private local settings +from publish output. + +For Azure, configure the same application variables on the serving app/slot's **Environment variables +→ App settings** page and resolve the private PEM through a Key Vault reference. Key Vault provider +credentials use managed identity, not the developer's CLI login. Use loopback-only local evaluation +or the offline tests' injected environment and secret resolver for local development. + ## Request behavior `POST /api/SendOtp` uses the same request and trust boundaries as the other runtimes. Incoming diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index 2633355..1163547 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -26,6 +26,20 @@ public static class EnvelopeParser public static string? ChannelName(int code) => ChannelByCode.TryGetValue(code, out var name) ? name : null; + public static async Task<(Envelope? Envelope, string? Error)> ParseAsync(Stream body, CancellationToken cancellationToken = default) + { + try + { + using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + return Parse(document.RootElement); + } + catch (Exception error) when (error is JsonException or DecoderFallbackException + || error is InvalidOperationException { InnerException: DecoderFallbackException }) + { + return (null, "invalid JSON body"); + } + } + public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) { if (payload.ValueKind != JsonValueKind.Object) @@ -89,6 +103,27 @@ public sealed class DeliveryContext [JsonPropertyName("locale")] public string? Locale { get; set; } [JsonPropertyName("message")] public string? Message { get; set; } [JsonPropertyName("riskContext")] public JsonElement? RiskContext { get; set; } + + [JsonIgnore] + public bool IsComplete => !string.IsNullOrWhiteSpace(Nonce) + && !string.IsNullOrWhiteSpace(PhoneNumber) + && !string.IsNullOrWhiteSpace(Message); + + public static DeliveryContext FromPayload(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return new(); + string? ReadString(string name) => payload.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + return new() + { + Nonce = ReadString("nonce"), + PhoneNumber = ReadString("phoneNumber"), + Message = ReadString("message"), + Extension = ReadString("extension"), + Locale = ReadString("locale"), + RiskContext = payload.TryGetProperty("riskContext", out var risk) ? risk.Clone() : null, + }; + } } public sealed record JweResult(string? Kid, string? Alg, string? Enc, DeliveryContext Context); @@ -115,7 +150,8 @@ public JweResult Decrypt(string compactJwe) var rsa = _keys.GetPrivateKey(kid); // Pin alg/enc so a tampered header can't downgrade the crypto. var plaintext = Jose.JWT.Decrypt(compactJwe, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); - var context = JsonSerializer.Deserialize(plaintext) ?? new DeliveryContext(); + using var payload = JsonDocument.Parse(plaintext); + var context = DeliveryContext.FromPayload(payload.RootElement); return new JweResult(kid, alg, enc, context); } diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index 54d5d84..e8f14c6 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -1,9 +1,23 @@ -using System.Text.Json; +using System.Text.Json.Serialization; namespace Epp.Otp; public enum Outcome { Continue, Fail, Block, StepUp } +public sealed record EndpointSuccessResponse( + [property: JsonPropertyName("nonce")] string Nonce, + [property: JsonPropertyName("correlationId")] string CorrelationId, + [property: JsonPropertyName("providerStatus")] string ProviderStatus = "accepted") +{ + public override string ToString() => nameof(EndpointSuccessResponse); +} + +public sealed record EndpointErrorResponse( + [property: JsonPropertyName("error")] string Error, + [property: JsonPropertyName("requestId"), JsonPropertyOrder(1)] string RequestId, + [property: JsonPropertyName("reason"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Reason = null, + [property: JsonPropertyName("correlationId"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? CorrelationId = null); + public sealed record DispatchRequest( string Destination, string? Message, @@ -22,7 +36,10 @@ public sealed record ParsedResponse( string? ProviderMessageId = null, string? ProviderStatusName = null, string? ProviderStatusCode = null, - string? ProviderStatusDescription = null); + string? ProviderStatusDescription = null) +{ + public override string ToString() => nameof(ParsedResponse); +} public sealed record AuthConfig(string Mode, string? KeyVaultSecretName = null, string? IdentityKeyVaultSecretName = null); diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index e19bdeb..e78a06b 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -36,19 +36,24 @@ public void SopranoUsesExactOmnimsgContract(string channel) } [Fact] - public void SopranoRequiresAnExplicitAcceptedStatus() + public void ProviderStatusesMapToExpectedOutcomesAndHttpCodes() { var adapter = new SopranoProvider(); Outcome Parse(string body) { using var json = JsonDocument.Parse(body); - return OutcomeMapper.ResolveOutcome(adapter.Manifest, adapter.ParseResponse(200, true, json.RootElement)); + var response = adapter.ParseResponse(200, true, json.RootElement); + Assert.Equal(nameof(ParsedResponse), response.ToString()); + return OutcomeMapper.ResolveOutcome(adapter.Manifest, response); } Assert.Equal(Outcome.Continue, Parse("[{\"id\":12,\"state\":\"enroute\"}]")); Assert.Equal(Outcome.Fail, Parse("{\"status\":\"FILTERED\"}")); Assert.Equal(Outcome.Fail, Parse("{\"status\":\"unknown\"}")); Assert.Equal(Outcome.Fail, Parse("{\"status\":123,\"state\":\"ACCEPTED\"}")); Assert.Equal(Outcome.Fail, Parse("{\"status\":false,\"state\":\"ACCEPTED\"}")); + Assert.Equal(403, OutcomeMapper.ToHttpStatus(Outcome.Block, 200)); + Assert.Equal(409, OutcomeMapper.ToHttpStatus(Outcome.StepUp, 200)); + Assert.Equal(429, OutcomeMapper.ToHttpStatus(Outcome.Fail, 429)); } [Fact] @@ -75,13 +80,6 @@ public void OtherProvidersKeepTheirStaticAuthenticationAndProtocols() Assert.Equal(Request().Message, callJson.RootElement.GetProperty("ttsCallout").GetProperty("text").GetString()); } - [Fact] - public void OutcomesMapToPublicHttpStatuses() - { - Assert.Equal(403, OutcomeMapper.ToHttpStatus(Outcome.Block, 200)); - Assert.Equal(409, OutcomeMapper.ToHttpStatus(Outcome.StepUp, 200)); - Assert.Equal(429, OutcomeMapper.ToHttpStatus(Outcome.Fail, 429)); - } } internal sealed class TestEnv : Dictionary, IEnv diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 420495e..6324883 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -125,6 +125,25 @@ public async Task PrivateKeyErrorsStayGenericAndNeverReachTheProvider() Assert.Equal(0, rig.Http.Calls); } + [Theory] + [InlineData("{", "decryption_failed", null)] + [InlineData("null", "bad_request", "incomplete delivery context")] + [InlineData("[]", "bad_request", "incomplete delivery context")] + [InlineData("{\"nonce\":123,\"phoneNumber\":\"phone\",\"message\":\"message\"}", "bad_request", "incomplete delivery context")] + [InlineData("{\"nonce\":\"nonce\",\"phoneNumber\":false,\"message\":\"message\"}", "bad_request", "incomplete delivery context")] + [InlineData("{\"nonce\":\"nonce\",\"phoneNumber\":\"phone\",\"message\":{}}", "bad_request", "incomplete delivery context")] + public async Task AuthenticatedPlaintextDistinguishesInvalidJsonFromIncompleteContext(string plaintext, string error, string? reason) + { + using var rig = new HandlerRig(); + var result = await rig.Invoke(plaintext: plaintext); + AssertFailure(rig, result, 400, error); + var body = JsonSerializer.SerializeToElement(result.Value); + if (reason is null) Assert.False(body.TryGetProperty("reason", out _)); + else Assert.Equal(reason, body.GetProperty("reason").GetString()); + Assert.Equal(Correlation, body.GetProperty("correlationId").GetString()); + Assert.Equal((1, 0, 0), (rig.Keys.Calls, rig.Secrets.Calls, rig.Http.Calls)); + } + [Fact] public async Task SharedInvalidRequestsReturnSafeReasonsBeforeProviderIo() { @@ -189,6 +208,7 @@ private static JsonDocument ReadContractFixtures() => private static void AssertAccepted(ObjectResult result) { Assert.Equal(200, result.StatusCode); + Assert.IsType(result.Value); Assert.Equal(JsonSerializer.Serialize(new { nonce = Nonce, correlationId = Correlation, providerStatus = "accepted" }), JsonSerializer.Serialize(result.Value)); } @@ -196,6 +216,7 @@ private static void AssertAccepted(ObjectResult result) private static void AssertFailure(HandlerRig rig, ObjectResult result, int status, string error = "provider_delivery_failed") { Assert.Equal(status, result.StatusCode); + Assert.IsType(result.Value); var body = JsonSerializer.SerializeToElement(result.Value); Assert.Equal(error, body.GetProperty("error").GetString()); Assert.False(body.TryGetProperty("nonce", out _)); @@ -230,12 +251,13 @@ public HandlerRig() } public async Task Invoke(object? mode = null, string channel = "sms", string? tenantId = null, Jose.JweAlgorithm algorithm = Jose.JweAlgorithm.RSA_OAEP_256, - Jose.JweEncryption encryption = Jose.JweEncryption.A256GCM, JsonElement? deliveryOverrides = null) + Jose.JweEncryption encryption = Jose.JweEncryption.A256GCM, JsonElement? deliveryOverrides = null, + string? plaintext = null) { var context = new Dictionary { ["nonce"] = Nonce, ["phoneNumber"] = Phone, ["message"] = Message }; if (deliveryOverrides is { } changes) foreach (var property in changes.EnumerateObject()) context[property.Name] = property.Value; - var encrypted = Jose.JWT.Encode(JsonSerializer.Serialize(context), Keys.Rsa, algorithm, encryption, + var encrypted = Jose.JWT.Encode(plaintext ?? JsonSerializer.Serialize(context), Keys.Rsa, algorithm, encryption, extraHeaders: new Dictionary { ["kid"] = Kid }); return await InvokeRaw(JsonSerializer.Serialize(new { diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs index 304a156..88e001c 100644 --- a/dotnet/tests/EnvelopeTests.cs +++ b/dotnet/tests/EnvelopeTests.cs @@ -10,7 +10,7 @@ public class EnvelopeTests [Theory] [InlineData("\"channel\":1,\"mode\":2,\"ttlSeconds\":60", "sms", 2, 60)] [InlineData("\"channel\":\"VOICE\",\"mode\":\"Live\"", "voice", 1, null)] - public void NumericAndNamedRoutingParse(string routing, string channel, int mode, int? ttl) + public async Task NumericAndNamedRoutingParse(string routing, string channel, int mode, int? ttl) { var (envelope, error) = Parse(routing); Assert.Null(error); @@ -18,6 +18,27 @@ public void NumericAndNamedRoutingParse(string routing, string channel, int mode Assert.Equal(channel, EnvelopeParser.ChannelName(envelope.Channel)); Assert.Equal(mode, envelope.Mode); Assert.Equal(ttl, envelope.TtlSeconds); + using var body = new MemoryStream(Encoding.UTF8.GetBytes(Payload(routing))); + Assert.Equal((envelope, error), await EnvelopeParser.ParseAsync(body)); + Assert.True(body.CanRead); + } + + [Fact] + public async Task StreamParserRejectsInvalidUtf8ButPropagatesCancellationAndReadErrors() + { + var bytes = Encoding.UTF8.GetBytes("{\"type\":\"private-input\"}"); + bytes[9] = 0xff; + using var invalidUtf8 = new MemoryStream(bytes); + var (envelope, error) = await EnvelopeParser.ParseAsync(invalidUtf8); + Assert.Null(envelope); + Assert.Equal("invalid JSON body", error); + + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + using var body = new MemoryStream(Encoding.UTF8.GetBytes("{}")); + await Assert.ThrowsAnyAsync(() => EnvelopeParser.ParseAsync(body, cancelled.Token)); + using var unreadable = new UnreadableBody(); + await Assert.ThrowsAsync(() => EnvelopeParser.ParseAsync(unreadable)); } [Fact] @@ -42,7 +63,7 @@ public void JweAuthenticatesOriginalProtectedHeaderBytes() var encodedHeader = Encode(Encoding.UTF8.GetBytes(header)); var key = RandomNumberGenerator.GetBytes(32); var iv = RandomNumberGenerator.GetBytes(12); - var plaintext = Encoding.UTF8.GetBytes("{\"nonce\":\"test-nonce\"}"); + var plaintext = Encoding.UTF8.GetBytes("{\"nonce\":\"test-nonce\",\"phoneNumber\":\"+15551234567\",\"message\":\"message\"}"); var ciphertext = new byte[plaintext.Length]; var tag = new byte[16]; using var cipher = new AesGcm(key, tag.Length); @@ -50,15 +71,26 @@ public void JweAuthenticatesOriginalProtectedHeaderBytes() var wrappedKey = keys.Rsa.Encrypt(key, RSAEncryptionPadding.OaepSHA256); var segments = new[] { encodedHeader, Encode(wrappedKey), Encode(iv), Encode(ciphertext), Encode(tag) }; var decryptor = new JweDecryptor(keys); - Assert.Equal("test-nonce", decryptor.Decrypt(string.Join(".", segments)).Context.Nonce); + var context = decryptor.Decrypt(string.Join(".", segments)).Context; + Assert.Equal("test-nonce", context.Nonce); + Assert.True(context.IsComplete); + Assert.False(JsonSerializer.SerializeToElement(context).TryGetProperty("IsComplete", out _)); segments[0] = Encode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(JsonSerializer.Deserialize(header)))); Assert.NotEqual(encodedHeader, segments[0]); Assert.ThrowsAny(() => decryptor.Decrypt(string.Join(".", segments))); } + private static string Payload(string routing) => + "{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"encryptedDeliveryContext\":\"x\"," + routing + "}"; + private static (Envelope? Envelope, string? Error) Parse(string routing) => - EnvelopeParser.Parse(JsonSerializer.Deserialize( - "{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"encryptedDeliveryContext\":\"x\"," + routing + "}")); + EnvelopeParser.Parse(JsonSerializer.Deserialize(Payload(routing))); + + private sealed class UnreadableBody : MemoryStream + { + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + ValueTask.FromException(new IOException("private read error")); + } } internal sealed class TestKeys : IJweKeyProvider, IDisposable diff --git a/javascript/README.md b/javascript/README.md index bf6254e..293e006 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -28,6 +28,51 @@ engine and one selected provider per deployment. API-specific behavior stays in behavior, not platform authentication; run the separate [deployed security checks](../docs/ONBOARDING.md#4-package-deploy-and-verify). +## Environment configuration + +Run Core Tools from `javascript/`. Create an untracked `local.settings.json` **beside +[host.json](host.json), not inside `src/`**. Start from the +[shared sample](../docs/local.settings.sample.json); for local evaluation, start Azurite and replace +the test-key placeholder in this minimal setup: + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "node", + "EPP_DECRYPTION_KEY_PEM": "" + } +} +``` + +For live delivery, add `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and `KEY_VAULT_URL` to `Values`. +Add `EPP_PROVIDER_ACCOUNT_NAME` and any adapter-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). + +Core Tools copies `Values` into the process environment; direct Node processes and the offline tests +do **not** automatically load this file. [AppConfig](src/functions/config.js) reads `process.env` +once per call to `readConfig()`. Restart the host after changing settings. Configure any local host +storage other than Azurite separately; do not copy a local emulator connection into Azure. Core Tools +does not resolve Key Vault references locally; supply the local test PEM or base64 PEM directly. + +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 +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. + +For the omnimsg adapter, the configured base ends in `/cgpapi`; the adapter appends `/messages/omnimsg` +for SMS and voice. QA4 is the test environment; select the provider-approved production base separately. +The base URL is not hard-coded and changing local settings does not change an already deployed app. + +For Azure, set these application variables on the Function App/slot's **Environment variables → App +settings** page and use a Key Vault reference for the private PEM. The provider-secret resolver uses +managed identity; signing into the CLI locally does not supply that identity. Local evaluation avoids +provider lookup, while tests inject mocked credentials and HTTP. Keep the local endpoint on loopback. + ## Request behavior Easy Auth authenticates and authorizes the caller before `POST /api/SendOtp`; the anonymous handler @@ -52,11 +97,13 @@ retries. The shared contract defines validation, HTTP outcomes and privacy-safe |---|---| | [src/functions/SendOtp.js](src/functions/SendOtp.js) | HTTP handler | | [src/functions/config.js](src/functions/config.js) | Shared deployment settings | +| [src/functions/models.js](src/functions/models.js) | Delivery context, normalized `ParsedResponse`, and documented request objects | | [src/functions/dispatch.js](src/functions/dispatch.js) | Envelope/JWE handling, registry and dispatch | | [src/functions/providers/](src/functions/providers/) | Adapter manifests and API-specific implementations | | [test/](test/) | Representative offline checks | To add an adapter, implement `manifest`, `buildRequest` and `parseResponse` in the adapter folder and -register it in [src/functions/dispatch.js](src/functions/dispatch.js). Keep credentials, options and +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 [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 08fbbc7..c207de3 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -64,8 +64,7 @@ app.http('SendOtp', { context.warn('encryption_key_id_mismatch'); } - if (!delivery || typeof delivery !== 'object' || Array.isArray(delivery) - || ['nonce', 'phoneNumber', 'message'].some((field) => typeof delivery[field] !== 'string' || !delivery[field].trim())) { + if (!delivery?.isComplete) { return respond(400, { error: 'bad_request', reason: 'incomplete delivery context', correlationId, requestId }); } diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js index 9d6603e..6b216a5 100644 --- a/javascript/src/functions/config.js +++ b/javascript/src/functions/config.js @@ -4,17 +4,23 @@ 'use strict'; -function readConfig(env = process.env) { - return { - decryptionKeyPem: env.EPP_DECRYPTION_KEY_PEM || '', - expectedKeyId: env.EPP_ENCRYPTION_KEY_ID || '', - providerName: (env.EPP_PROVIDER_NAME || '').trim().toLowerCase(), - providerEndpoint: env.EPP_PROVIDER_ENDPOINT || '', - providerTimeoutMs: env.EPP_PROVIDER_TIMEOUT_MS || '', - keyVaultUrl: (env.KEY_VAULT_URL || '').trim(), - managedIdentityClientId: (env.AZURE_CLIENT_ID || '').trim(), - env, - }; +const { inspect } = require('node:util'); + +class AppConfig { + constructor(env = process.env) { + this.decryptionKeyPem = env.EPP_DECRYPTION_KEY_PEM || ''; + this.expectedKeyId = env.EPP_ENCRYPTION_KEY_ID || ''; + this.providerName = (env.EPP_PROVIDER_NAME || '').trim().toLowerCase(); + this.providerEndpoint = env.EPP_PROVIDER_ENDPOINT || ''; + this.providerTimeoutMs = env.EPP_PROVIDER_TIMEOUT_MS || ''; + this.keyVaultUrl = (env.KEY_VAULT_URL || '').trim(); + this.managedIdentityClientId = (env.AZURE_CLIENT_ID || '').trim(); + this.env = env; + } + + [inspect.custom]() { return '[AppConfig]'; } } -module.exports = { readConfig }; +const readConfig = (env = process.env) => new AppConfig(env); + +module.exports = { AppConfig, readConfig }; diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index 1e48de2..e33383e 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -9,6 +9,7 @@ const { compactDecrypt } = require('jose'); const { ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('./config'); +const { DeliveryContext } = require('./models'); const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); @@ -30,6 +31,7 @@ function normalizeMode(mode) { return null; } +/** @returns {{envelope?: import('./models').Envelope, error?: string}} */ function parseEnvelope(payload) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return { error: 'invalid envelope' }; @@ -111,9 +113,15 @@ async function decryptDeliveryContext(compactJwe, config = readConfig()) { keyManagementAlgorithms: ['RSA-OAEP-256'], contentEncryptionAlgorithms: ['A256GCM'], }); - return { header, context: JSON.parse(Buffer.from(plaintext).toString('utf8')) }; + 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 { @@ -200,6 +208,7 @@ async function resolveProviderCredential(authConfiguration = {}, config) { } // 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; diff --git a/javascript/src/functions/models.js b/javascript/src/functions/models.js new file mode 100644 index 0000000..ce863a5 --- /dev/null +++ b/javascript/src/functions/models.js @@ -0,0 +1,75 @@ +'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 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; + } + + 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, ParsedResponse }; diff --git a/javascript/src/functions/providers/infobip.js b/javascript/src/functions/providers/infobip.js index ee3aa2b..b0b1010 100644 --- a/javascript/src/functions/providers/infobip.js +++ b/javascript/src/functions/providers/infobip.js @@ -4,6 +4,8 @@ 'use strict'; +const { ParsedResponse } = require('../models'); + // Voice integration is unverified; confirm the request format before production use. const manifest = { @@ -55,12 +57,12 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { function parseResponse({ httpStatus, ok, json }) { const firstMessage = json && json.messages && json.messages[0]; const status = (firstMessage && firstMessage.status) || {}; - return { + return new ParsedResponse({ success: ok, providerHttpStatus: httpStatus, providerMessageId: (firstMessage && firstMessage.messageId) || null, providerStatusName: (status.groupName || status.name || '').toUpperCase() || null, - }; + }); } module.exports = { manifest, buildRequest, parseResponse }; diff --git a/javascript/src/functions/providers/sinch.js b/javascript/src/functions/providers/sinch.js index 6376dfa..d519d12 100644 --- a/javascript/src/functions/providers/sinch.js +++ b/javascript/src/functions/providers/sinch.js @@ -4,6 +4,8 @@ 'use strict'; +const { ParsedResponse } = require('../models'); + // A batch identifier indicates acceptance, not final delivery; delivery status arrives by callback. const manifest = { @@ -54,12 +56,12 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { function parseResponse({ httpStatus, ok, json }) { const messageOrCallId = (json && (json.id || json.callId || json._links && json._links.self)) || null; - return { + 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, - }; + }); } module.exports = { manifest, buildRequest, parseResponse }; diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index ed4886b..de86805 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -4,6 +4,8 @@ 'use strict'; +const { ParsedResponse } = require('../models'); + const manifest = { id: 'soprano', auth: { @@ -51,12 +53,12 @@ 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 { + return new ParsedResponse({ success: ok, providerHttpStatus: httpStatus, providerMessageId: (payload.id != null ? String(payload.id) : null) || payload.messageId || null, providerStatusName: status, - }; + }); } module.exports = { manifest, buildRequest, parseResponse }; diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index 705953e..5ba03a1 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -4,6 +4,8 @@ 'use strict'; +const { ParsedResponse } = require('../models'); + const manifest = { id: 'telesign', auth: { @@ -68,13 +70,12 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { function parseResponse({ httpStatus, ok, json }) { const status = (json && json.status) || {}; - return { + return new ParsedResponse({ success: ok, providerHttpStatus: httpStatus, providerMessageId: (json && json.reference_id) || null, providerStatusCode: status.code != null ? String(status.code) : null, - providerStatusName: null, - }; + }); } module.exports = { manifest, buildRequest, parseResponse }; diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 61ed9f8..28d7f51 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -3,7 +3,10 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { SecretClient } = require('@azure/keyvault-secrets'); -const { readConfig } = require('../src/functions/config'); +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 { dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus, parseEnvelope, parseProviderTimeout, isValidProviderUrl, @@ -23,6 +26,8 @@ test('config uses the deployment provider, with no hardcoded fallback', async (t 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, ''); @@ -38,14 +43,28 @@ test('config uses the deployment provider, with no hardcoded fallback', async (t assert.equal(JSON.parse(init.body).body, dispatch.message); }); -test('envelope TTL boundaries and routing reject coercion', () => { +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); - for (const ttlSeconds of [-1, 0, '60', null, true, 1.5, 2147483648]) { - assert.ok(parseEnvelope(envelope({ ttlSeconds })).error, String(ttlSeconds)); +}); + +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); } - assert.equal(parseEnvelope(envelope({ channel: '1' })).error, 'unsupported channel'); - assert.equal(parseEnvelope(envelope({ mode: true })).error, 'unsupported mode'); }); test('provider URLs and timeouts retain representative safety boundaries', () => { @@ -61,7 +80,7 @@ test('provider URLs and timeouts retain representative safety boundaries', () => assert.equal(parseProviderTimeout('9999'), 2500); }); -test('omnimsg uses API ID/key headers and a constant false shutterMode wire field', () => { +test('omnimsg preserves its API-key request and normalizes acceptance', () => { const request = getProvider('soprano').adapter.buildRequest({ ...input, env: undefined, endpoint: `${input.endpoint}/cgpapi///` }); assert.equal(request.url, 'https://provider.example/cgpapi/messages/omnimsg'); assert.equal(request.method, 'POST'); @@ -69,30 +88,46 @@ test('omnimsg uses API ID/key headers and a constant false shutterMode wire fiel 'X-MEMS-API-ID': 'id', 'X-MEMS-API-Key': 'key' }); assert.deepEqual(JSON.parse(request.body), { text: dispatch.message, destination: '15551234567', messageTypes: ['sms'], correlationId: 'correlation-id', shutterMode: false }); + 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('App authentication uses JSON SMS content', () => { +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('Basic authentication uses form-encoded SMS content', () => { +test('Basic-auth SMS preserves its form request and normalizes acceptance', () => { const request = getProvider('telesign').adapter.buildRequest(input); assert.equal(request.url, 'https://provider.example/v1/messaging'); assert.equal(request.headers.Authorization, `Basic ${Buffer.from('id:key').toString('base64')}`); assert.equal(request.headers['Content-Type'], 'application/x-www-form-urlencoded'); assert.equal(new URLSearchParams(request.body).get('message'), dispatch.message); + 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('static Bearer authentication uses the service-plan SMS route', () => { +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', () => { diff --git a/python/README.md b/python/README.md index 968ded0..de7afdb 100644 --- a/python/README.md +++ b/python/README.md @@ -28,6 +28,41 @@ provider per deployment. Target: Python 3.11, Azure Functions v4, Python v2 prog Offline tests cover application behavior, not platform authentication; run the separate [deployed security checks](../docs/ONBOARDING.md#4-package-deploy-and-verify). +## Environment configuration + +Run Core Tools from `python/`. Create an untracked `local.settings.json` beside +[host.json](host.json), starting from the [shared sample](../docs/local.settings.sample.json). +For local evaluation, start Azurite and replace the test-key placeholder in this minimal setup: + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "EPP_DECRYPTION_KEY_PEM": "" + } +} +``` + +For live delivery, add `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and `KEY_VAULT_URL` to `Values`. +Add `EPP_PROVIDER_ACCOUNT_NAME` and any adapter-specific options only when required. Keep values as +strings, including optional `EPP_PROVIDER_TIMEOUT_MS: "1500"`. Replace placeholders; provider API +keys belong in the manifest-named Key Vault secrets, not this file. See the +[complete variable table](../README.md#configure-environment-variables). + +Core Tools loads `Values` into `os.environ`. Direct Python execution and pytest do not automatically +read local settings. [read_config](src/config.py) returns an `AppConfig` object; the handler/engine +use attributes such as `config.provider_name`, not dictionary key lookups. Restart the host after +settings change. Configure local host storage other than Azurite separately; do not copy the emulator +connection into Azure. Core Tools does not resolve Key Vault references locally; supply the local test +PEM or base64 PEM directly. + +For Azure, set the same application variables on the serving app/slot's **Environment variables → App +settings** page. Use a Key Vault reference for the private PEM. Provider secrets require managed +identity, which is not supplied by a developer's CLI login. Use local evaluation or the mocked offline +tests on an ordinary workstation, and bind local hosts only to loopback. + ## Request behavior `POST /api/SendOtp` uses the same request and trust boundaries as the other runtimes. Incoming @@ -50,9 +85,12 @@ Platform/key prerequisites and HTTP outcomes are defined in the |---|---| | [function_app.py](function_app.py) | HTTP handler and adapter registration | | [src/config.py](src/config.py) | Shared deployment settings | -| [src/dispatch.py](src/dispatch.py) | Request model, JWE, provider registry and outcome mapping | +| [src/models.py](src/models.py) | Envelope, delivery-context, dispatch and normalized `ParsedResponse` dataclasses | +| [src/dispatch.py](src/dispatch.py) | Boundary validation, JWE, provider registry and outcome mapping | | [src/providers/](src/providers/) | Adapter manifests and API-specific implementations | | [src/secrets.py](src/secrets.py) | Cached Key Vault access via managed identity | Add and register an adapter without adding provider-specific branches to the shared pipeline. +Return `ParsedResponse` from `parse_response` using named fields; the engine reads attributes such as +`parsed.provider_status_name`. Raw provider JSON remains local to the adapter, not a shared model hierarchy. See [production limitations](../docs/CONTRACT.md#production-limitations) before production use. diff --git a/python/function_app.py b/python/function_app.py index 6286cd2..61ab41d 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -61,22 +61,19 @@ def respond(status, body): if error: return respond(400, {"error": "bad_request", "reason": error, "requestId": request_id}) - correlation_id = envelope["correlation_id"] or header_correlation_id or request_id - envelope["correlation_id"] = correlation_id - evaluation = envelope["mode"] == MODE_EVALUATION + correlation_id = envelope.correlation_id or header_correlation_id or request_id + envelope.correlation_id = correlation_id + evaluation = envelope.mode == MODE_EVALUATION try: - header, delivery = decrypt_delivery_context(envelope["encrypted_delivery_context"], _key_provider) + header, delivery = decrypt_delivery_context(envelope.encrypted_delivery_context, _key_provider) except Exception: return respond(400, {"error": "decryption_failed", "correlationId": correlation_id, "requestId": request_id}) - if config["expected_key_id"] and header.get("kid") != config["expected_key_id"]: + if config.expected_key_id and header.get("kid") != config.expected_key_id: logging.warning('encryption_key_id_mismatch') - if not isinstance(delivery, dict) or not all( - isinstance(delivery.get(field), str) and delivery[field].strip() - for field in ("nonce", "phoneNumber", "message") - ): + if delivery is None or not delivery.is_complete: return respond(400, {"error": "bad_request", "reason": "incomplete delivery context", "correlationId": correlation_id, "requestId": request_id}) @@ -90,12 +87,12 @@ def respond(status, body): # Live delivery must finish before nonce acceptance. return respond(200, { - "nonce": delivery["nonce"], + "nonce": delivery.nonce, "correlationId": correlation_id, "providerStatus": "accepted", }) except Exception: - return respond(500, {"error": "provider_delivery_failed", "correlationId": correlation_id, "requestId": request_id}) + return respond(500, {"error": "delivery_failed", "correlationId": correlation_id, "requestId": request_id}) finally: # Hash even generated correlations; wire IDs stay raw. logging.info("%s result %s", TAG, json.dumps({ diff --git a/python/src/config.py b/python/src/config.py index 442c307..c1fd9f6 100644 --- a/python/src/config.py +++ b/python/src/config.py @@ -1,13 +1,25 @@ import os +from collections.abc import Mapping +from dataclasses import dataclass -def read_config(env=None): +@dataclass(repr=False) +class AppConfig: + decryption_key_pem: str + expected_key_id: str | None + provider_name: str + provider_endpoint: str | None + provider_timeout_ms: str | None + env: Mapping[str, str] + + +def read_config(env: Mapping[str, str] | None = None) -> AppConfig: env = os.environ if env is None else env - return { - "decryption_key_pem": env.get("EPP_DECRYPTION_KEY_PEM") or "", - "expected_key_id": env.get("EPP_ENCRYPTION_KEY_ID"), - "provider_name": (env.get("EPP_PROVIDER_NAME") or "").strip().lower(), - "provider_endpoint": env.get("EPP_PROVIDER_ENDPOINT"), - "provider_timeout_ms": env.get("EPP_PROVIDER_TIMEOUT_MS"), - "env": env, # Preserve raw adapter settings and the injected environment. - } \ No newline at end of file + return AppConfig( + decryption_key_pem=env.get("EPP_DECRYPTION_KEY_PEM") or "", + expected_key_id=env.get("EPP_ENCRYPTION_KEY_ID"), + provider_name=(env.get("EPP_PROVIDER_NAME") or "").strip().lower(), + provider_endpoint=env.get("EPP_PROVIDER_ENDPOINT"), + provider_timeout_ms=env.get("EPP_PROVIDER_TIMEOUT_MS"), + env=env, # Preserve raw adapter settings and the injected environment. + ) \ No newline at end of file diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 0eb50d1..47c7ccd 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -1,7 +1,6 @@ import base64 import json import os -from dataclasses import dataclass from urllib.parse import urlsplit import requests @@ -10,6 +9,7 @@ from urllib3.exceptions import ReadTimeoutError from .config import read_config +from .models import DeliveryContext, DispatchRequest, Envelope, ParsedResponse DEFAULT_TIMEOUT_MS = 1500 DEFAULT_CHANNELS = ["sms", "voice"] @@ -20,24 +20,14 @@ STEP_UP = "StepUp" -@dataclass -class DispatchRequest: - destination: str - message: str | None - channel: str - message_id: str - correlation_id: str | None - locale: str | None - - -def resolve_outcome(manifest, parsed): +def resolve_outcome(manifest, parsed: ParsedResponse): mapping = manifest["response_mapping"] - key = parsed.get("provider_status_name") or parsed.get("provider_status_code") + key = parsed.provider_status_name or parsed.provider_status_code if key: outcome = mapping.get(key) or mapping.get("default", FAIL) else: - outcome = CONTINUE if parsed.get("success") else mapping.get("default", FAIL) - return FAIL if outcome == CONTINUE and not parsed.get("success") else outcome + outcome = CONTINUE if parsed.success else mapping.get("default", FAIL) + return FAIL if outcome == CONTINUE and not parsed.success else outcome def to_http_status(outcome, provider_http_status): @@ -92,7 +82,7 @@ def _normalize_mode(mode): return None -def parse_envelope(payload): +def parse_envelope(payload) -> tuple[Envelope | None, str | None]: if not isinstance(payload, dict): return None, "invalid envelope" if payload.get("type") != "microsoft.mfa.otpDeliver.v1": @@ -114,15 +104,15 @@ def parse_envelope(payload): return None, "ttlSeconds expired" if ttl_seconds > 2147483647: return None, "invalid ttlSeconds" - return { - "type": payload.get("type"), - "tenant_id": payload.get("tenantId"), - "correlation_id": payload.get("correlationId"), - "channel": channel, - "mode": mode, - "ttl_seconds": ttl_seconds, - "encrypted_delivery_context": encrypted, - }, None + return Envelope( + type=payload.get("type"), + tenant_id=payload.get("tenantId"), + correlation_id=payload.get("correlationId"), + channel=channel, + mode=mode, + ttl_seconds=ttl_seconds, + encrypted_delivery_context=encrypted, + ), None def read_protected_header(compact_jwe): @@ -133,7 +123,7 @@ def read_protected_header(compact_jwe): def make_key_provider(env): def key_provider(_kid): - return read_config(env)["decryption_key_pem"] + return read_config(env).decryption_key_pem return key_provider @@ -177,17 +167,18 @@ def decrypt_delivery_context(compact_jwe, key_provider): key = _load_private_key(key_provider(header.get("kid"))) token = jwe_module.JWE(algs=["RSA-OAEP-256", "A256GCM"]) token.deserialize(compact_jwe, key=key) - return header, json.loads(token.payload.decode("utf-8")) + payload = json.loads(token.payload.decode("utf-8")) + return header, DeliveryContext.from_payload(payload) def context_to_dispatch(context, envelope, message_id): return DispatchRequest( - destination=context.get("phoneNumber"), - message=context.get("message"), - channel=CHANNEL_BY_CODE[envelope["channel"]], + destination=context.phone_number, + message=context.message, + channel=CHANNEL_BY_CODE[envelope.channel], message_id=message_id, - correlation_id=envelope["correlation_id"], - locale=context.get("locale"), + correlation_id=envelope.correlation_id, + locale=context.locale, ) @@ -252,7 +243,7 @@ def __init__(self, registry, secrets, env=None): def dispatch(self, dispatch, request_id): config = read_config(self.env) - adapter = self.registry.get(config["provider_name"]) + adapter = self.registry.get(config.provider_name) if adapter is None: return 400, {"status": "error", "reason": "unknown provider", "requestId": request_id} @@ -276,20 +267,20 @@ def dispatch(self, dispatch, request_id): if not credential.get("secret") or (auth.get("identity_key_vault_secret_name") and not credential.get("identity")): return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) - endpoint = config["provider_endpoint"] + endpoint = config.provider_endpoint if not endpoint: return 502, self._fail_body(provider_id, channel, "provider endpoint not configured", dispatch, request_id) if not _valid_provider_url(endpoint): return 502, self._fail_body(provider_id, channel, "invalid provider endpoint", dispatch, request_id) try: - provider_request = adapter.build_request(channel, endpoint, dispatch, credential, config["env"]) + provider_request = adapter.build_request(channel, endpoint, dispatch, credential, config.env) except Exception: return 502, self._fail_body(provider_id, channel, "provider request failed", dispatch, request_id) if not _valid_provider_url(provider_request.get("url")): return 502, self._fail_body(provider_id, channel, "invalid provider request URL", dispatch, request_id) - timeout_ms = _provider_timeout_ms(config["provider_timeout_ms"]) + timeout_ms = _provider_timeout_ms(config.provider_timeout_ms) response = None try: response = requests.request( @@ -311,7 +302,7 @@ def dispatch(self, dispatch, request_id): ok = 200 <= response.status_code < 300 parsed = adapter.parse_response(response.status_code, ok, body_json) outcome = resolve_outcome(manifest, parsed) - http_status = to_http_status(outcome, parsed.get("provider_http_status") or response.status_code) + http_status = to_http_status(outcome, parsed.provider_http_status or response.status_code) return http_status, { "status": "accepted" if outcome == CONTINUE else "failed", diff --git a/python/src/models.py b/python/src/models.py new file mode 100644 index 0000000..8564af4 --- /dev/null +++ b/python/src/models.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass + + +@dataclass(repr=False) +class Envelope: + type: str + tenant_id: object + correlation_id: object + channel: int + mode: int + ttl_seconds: int | None + encrypted_delivery_context: str + + +@dataclass(repr=False) +class DeliveryContext: + # Keep raw JSON values until is_complete validates the required strings. + nonce: object + phone_number: object + message: object + locale: object = None + extension: object = None + risk_context: object = None + + @classmethod + def from_payload(cls, payload: object) -> "DeliveryContext | None": + if not isinstance(payload, dict): + return None + return cls( + nonce=payload.get("nonce"), + phone_number=payload.get("phoneNumber"), + message=payload.get("message"), + locale=payload.get("locale"), + extension=payload.get("extension"), + risk_context=payload.get("riskContext"), + ) + + @property + def is_complete(self) -> bool: + return all( + isinstance(value, str) and value.strip() + for value in (self.nonce, self.phone_number, self.message) + ) + + +@dataclass(repr=False) +class DispatchRequest: + destination: str + message: str | None + channel: str + message_id: str + correlation_id: str | None + locale: str | None + + +@dataclass(repr=False) +class ParsedResponse: + """Adapter-normalized result for outcome mapping, not a public HTTP response.""" + + success: bool + provider_http_status: int + provider_message_id: str | None = None + provider_status_name: str | None = None + provider_status_code: str | None = None + provider_status_description: str | None = None \ No newline at end of file diff --git a/python/src/providers/infobip.py b/python/src/providers/infobip.py index aad5d63..eb8de22 100644 --- a/python/src/providers/infobip.py +++ b/python/src/providers/infobip.py @@ -1,5 +1,7 @@ import json +from ..models import ParsedResponse + class InfobipProvider: manifest = { @@ -44,11 +46,10 @@ def parse_response(self, http_status, ok, json_body): first_message = messages[0] if messages else {} status = first_message.get("status") or {} status_name = (status.get("groupName") or status.get("name") or "").upper() or None - return { - "success": ok, - "provider_http_status": http_status, - "provider_message_id": first_message.get("messageId"), - "provider_status_name": status_name, - "provider_status_code": None, - "provider_status_description": status.get("description"), - } + return ParsedResponse( + success=ok, + provider_http_status=http_status, + provider_message_id=first_message.get("messageId"), + provider_status_name=status_name, + provider_status_description=status.get("description"), + ) diff --git a/python/src/providers/sinch.py b/python/src/providers/sinch.py index 8ce2c7c..cb6e3fa 100644 --- a/python/src/providers/sinch.py +++ b/python/src/providers/sinch.py @@ -1,5 +1,7 @@ import json +from ..models import ParsedResponse + class SinchProvider: manifest = { @@ -39,11 +41,10 @@ def parse_response(self, http_status, ok, json_body): identifier = None if isinstance(json_body, dict): identifier = json_body.get("id") or json_body.get("callId") - return { - "success": ok, - "provider_http_status": http_status, - "provider_message_id": str(identifier) if identifier is not None else None, - "provider_status_name": "Dispatched" if ok else None, - "provider_status_code": None, - "provider_status_description": json_body.get("text") if isinstance(json_body, dict) else None, - } + return ParsedResponse( + success=ok, + provider_http_status=http_status, + provider_message_id=str(identifier) if identifier is not None else None, + provider_status_name="Dispatched" if ok else None, + provider_status_description=json_body.get("text") if isinstance(json_body, dict) else None, + ) diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index d6ae863..93e088b 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -1,5 +1,7 @@ import json +from ..models import ParsedResponse + class SopranoProvider: manifest = { @@ -42,10 +44,9 @@ def parse_response(self, http_status, ok, json_body): if value is None: value = payload.get("state") status = value.upper() if isinstance(value, str) and value else "UNKNOWN" - return { - "success": ok, - "provider_http_status": http_status, - "provider_message_id": identifier, - "provider_status_name": status, - "provider_status_code": None, - } + return ParsedResponse( + success=ok, + provider_http_status=http_status, + provider_message_id=identifier, + provider_status_name=status, + ) diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py index 7d1480c..58bd95a 100644 --- a/python/src/providers/telesign.py +++ b/python/src/providers/telesign.py @@ -1,6 +1,8 @@ import base64 import urllib.parse +from ..models import ParsedResponse + class TelesignProvider: manifest = { @@ -48,11 +50,10 @@ def build_request(self, channel, endpoint, dispatch, credential, env): def parse_response(self, http_status, ok, json_body): status = json_body.get("status") or {} if isinstance(json_body, dict) else {} code = status.get("code") - return { - "success": ok, - "provider_http_status": http_status, - "provider_message_id": json_body.get("reference_id") if isinstance(json_body, dict) else None, - "provider_status_name": None, - "provider_status_code": str(code) if code is not None else None, - "provider_status_description": status.get("description"), - } + return ParsedResponse( + success=ok, + provider_http_status=http_status, + provider_message_id=json_body.get("reference_id") if isinstance(json_body, dict) else None, + provider_status_code=str(code) if code is not None else None, + provider_status_description=status.get("description"), + ) diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index 3357a8c..9bf87f7 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -1,10 +1,12 @@ import base64 import json +from pathlib import Path from urllib.parse import parse_qs import pytest -from src.dispatch import DispatchRequest, ProviderRegistry, parse_envelope +from src.dispatch import DispatchRequest, ProviderRegistry, context_to_dispatch, parse_envelope +from src.models import DeliveryContext, Envelope, ParsedResponse from src.providers.infobip import InfobipProvider from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider @@ -33,9 +35,12 @@ def test_soprano_exact_sms_and_voice_contract(channel): "text": MESSAGE, "destination": "15551234567", "messageTypes": [channel], "correlationId": "correlation-id", "shutterMode": False, } + response = SopranoProvider().parse_response(201, True, {"id": 123, "status": "ENROUTE"}) + assert response == ParsedResponse(True, 201, provider_message_id="123", provider_status_name="ENROUTE") + assert "ENROUTE" not in repr(response) -def test_infobip_sms_contract(): +def test_infobip_sms_request_and_response_contract(): request = InfobipProvider().build_request( "sms", "https://infobip.example", _dispatch(), {"mode": "apiKey", "secret": "ib"}, {"EPP_PROVIDER_ACCOUNT_NAME": "EPP"}, @@ -46,9 +51,13 @@ def test_infobip_sms_contract(): "sender": "EPP", "destinations": [{"to": "+15551234567", "messageId": "correlation-id"}], "content": {"text": MESSAGE}, }] + response = InfobipProvider().parse_response(200, True, { + "messages": [{"messageId": "message-id", "status": {"groupName": "PENDING"}}], + }) + assert response == ParsedResponse(True, 200, provider_message_id="message-id", provider_status_name="PENDING") -def test_telesign_sms_contract(): +def test_telesign_sms_request_and_response_contract(): request = TelesignProvider().build_request( "sms", "https://telesign.example", _dispatch(), {"mode": "apiKey", "secret": "key", "identity": "customer"}, {}, @@ -59,9 +68,11 @@ def test_telesign_sms_contract(): form = parse_qs(request["body"]) assert form["phone_number"] == ["+15551234567"] and form["message"] == [MESSAGE] assert form["message_type"] == ["OTP"] and form["external_id"] == ["correlation-id"] + response = TelesignProvider().parse_response(200, True, {"reference_id": "message-id", "status": {"code": 290}}) + assert response == ParsedResponse(True, 200, provider_message_id="message-id", provider_status_code="290") -def test_sinch_sms_static_token_contract(): +def test_sinch_sms_request_and_response_contract(): request = SinchProvider().build_request( "sms", "https://sinch.example", _dispatch(), {"mode": "apiKey", "secret": "static-api-token"}, @@ -72,20 +83,43 @@ def test_sinch_sms_static_token_contract(): assert json.loads(request["body"]) == { "from": "EPP", "to": ["+15551234567"], "body": MESSAGE, "client_reference": "correlation-id", } + response = SinchProvider().parse_response(200, True, {"id": "message-id"}) + assert response == ParsedResponse(True, 200, provider_message_id="message-id", provider_status_name="Dispatched") -def test_envelope_routing_and_ttl_validation(): +def test_request_models_preserve_content_and_accept_valid_routing_and_ttl(): payload = {"type": "microsoft.mfa.otpDeliver.v1", "channel": 1, "mode": 1, "encryptedDeliveryContext": "jwe"} for channel, mode, expected in ((1, 1, (1, 1)), ("VOICE", "Evaluation", (2, 2))): envelope, error = parse_envelope({**payload, "channel": channel, "mode": mode}) - assert error is None and (envelope["channel"], envelope["mode"]) == expected - for changes in ({"channel": True}, {"mode": False}, {"channel": "1"}, {"mode": None}): - envelope, error = parse_envelope({**payload, **changes}) - assert envelope is None and error - for ttl in (None, True, "60", 0, 2147483648): - envelope, error = parse_envelope({**payload, "ttlSeconds": ttl}) - assert envelope is None and "ttlSeconds" in error + assert error is None and isinstance(envelope, Envelope) + assert (envelope.channel, envelope.mode) == expected for ttl in (1, 2147483647): envelope, error = parse_envelope({**payload, "ttlSeconds": ttl}) - assert error is None and envelope["ttl_seconds"] == ttl - assert parse_envelope(payload)[0]["ttl_seconds"] is None + assert error is None and envelope.ttl_seconds == ttl + envelope, error = parse_envelope(payload) + assert error is None and envelope.ttl_seconds is None + + context = DeliveryContext.from_payload({ + "nonce": " nonce ", "phoneNumber": "+15551234567", "message": MESSAGE, + "locale": {"opaque": "metadata"}, + }) + assert isinstance(context, DeliveryContext) and context.is_complete + dispatch = context_to_dispatch(context, envelope, "message-id") + assert isinstance(dispatch, DispatchRequest) + assert context.nonce == " nonce " and dispatch.message == MESSAGE + assert dispatch.destination == context.phone_number and dispatch.locale is context.locale + assert MESSAGE not in repr(context) + repr(dispatch) + assert "encrypted_delivery_context" not in repr(envelope) + assert DeliveryContext.from_payload(None) is None + + +def test_envelope_parser_rejects_invalid_inputs_with_the_contract_reason(): + fixtures = json.loads((Path(__file__).resolve().parents[2] / "tests/fixtures/contract.json").read_text()) + valid = {"type": "microsoft.mfa.otpDeliver.v1", "channel": 1, "mode": 1, "encryptedDeliveryContext": "jwe"} + for fixture in fixtures["badRequests"]: + # Malformed JSON is handled before the parser receives an object. + if fixture["reason"] == "invalid JSON body": + continue + payload = json.loads(fixture["rawBody"]) if "rawBody" in fixture else {**valid, **fixture["overrides"]} + envelope, error = parse_envelope(payload) + assert envelope is None and error == fixture["reason"], fixture["name"] diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index 545258e..6d352f8 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -4,6 +4,7 @@ from urllib3.exceptions import ReadTimeoutError import src.dispatch as dispatch_module +from src.config import AppConfig, read_config from src.dispatch import DispatchEngine, DispatchRequest, ProviderRegistry from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider @@ -45,6 +46,11 @@ def test_base_and_sinch_voice_final_url_guards(engine): def test_provider_outcomes_fail_closed(engine, monkeypatch): monkeypatch.setenv("EPP_PROVIDER_NAME", "sinch") # The injected provider setting must win. + engine.env["EPP_DECRYPTION_KEY_PEM"] = "test-private-pem" + config = read_config(engine.env) + assert isinstance(config, AppConfig) and config.provider_name == "soprano" + assert config.env is engine.env and config.decryption_key_pem == "test-private-pem" + assert "test-private-pem" not in repr(config) and "EPP_PROVIDER_NAME" not in repr(config) assert engine.registry.get(None) is None cases = ( (202, {"state": "accepted"}, 200, "Continue"), diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index 8c9ae30..07a088d 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -83,8 +83,9 @@ def test_shared_invalid_requests_return_safe_reasons_before_provider_io(): assert response.status_code == 400 and result["requestId"] assert result == {"error": "bad_request", "reason": fixture["reason"], "requestId": result["requestId"]}, fixture["name"] - for changes in _FIXTURES["incompleteContexts"]: - payload = _envelope(mode=2, encryptedDeliveryContext=_encrypt(context={**_CONTEXT, **changes})) + contexts = [{**_CONTEXT, **changes} for changes in _FIXTURES["incompleteContexts"]] + for context in (*contexts, False, []): + payload = _envelope(mode=2, encryptedDeliveryContext=_encrypt(context=context)) response = _HANDLER(_request(payload)) result = json.loads(response.get_body()) assert response.status_code == 400 @@ -195,3 +196,13 @@ def test_provider_failure_preserves_status_without_retry_or_nonce(monkeypatch): send.assert_called_once() assert send.call_args.kwargs["allow_redirects"] is False upstream.close.assert_called_once() + + +def test_unexpected_handler_error_is_generic_and_does_not_send(monkeypatch, caplog): + monkeypatch.setattr(function_app, "read_config", Mock(side_effect=RuntimeError("PRIVATE-ERROR"))) + response = _HANDLER(_request({})) + body = json.loads(response.get_body()) + assert response.status_code == 500 and body["error"] == "delivery_failed" + assert set(body) == {"error", "correlationId", "requestId"} + assert "PRIVATE-ERROR" not in response.get_body().decode() + caplog.text + dispatch_module.requests.request.assert_not_called() diff --git a/tests/fixtures/contract.json b/tests/fixtures/contract.json index ab2bbdf..4d6c5cd 100644 --- a/tests/fixtures/contract.json +++ b/tests/fixtures/contract.json @@ -13,6 +13,8 @@ { "name": "boolean channel", "overrides": { "channel": true }, "reason": "unsupported channel" }, { "name": "numeric string channel", "overrides": { "channel": "1" }, "reason": "unsupported channel" }, { "name": "null mode", "overrides": { "mode": null }, "reason": "unsupported mode" }, + { "name": "boolean mode", "overrides": { "mode": true }, "reason": "unsupported mode" }, + { "name": "false mode", "overrides": { "mode": false }, "reason": "unsupported mode" }, { "name": "empty encryption", "overrides": { "encryptedDeliveryContext": "" }, "reason": "encryptedDeliveryContext is required" }, { "name": "blank encryption", "overrides": { "encryptedDeliveryContext": " " }, "reason": "encryptedDeliveryContext is required" }, { "name": "string TTL", "overrides": { "ttlSeconds": "60" }, "reason": "invalid ttlSeconds" }, @@ -27,7 +29,10 @@ ], "incompleteContexts": [ { "nonce": "" }, + { "nonce": null }, + { "nonce": 123 }, { "phoneNumber": null }, - { "message": " " } + { "phoneNumber": "" }, + { "message": " \t\r\n" } ] } \ No newline at end of file