From 5a185c502291c0349688ee813ab18b20de07e37c Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Mon, 24 Aug 2026 09:40:54 -0700 Subject: [PATCH 1/4] Soprano: switch to the omnimsg endpoint contract One endpoint for every channel: POST {base}/messages/omnimsg with text/destination/messageTypes/correlationId/shutterMode. Soprano renders voice TTS from the same text, so the text2voice block and the provisioned-source fields go away; destination is E.164 without the leading +. SOPRANO_SHUTTER_MODE opts into Soprano-side shutter for connectivity tests. --- dotnet/Src/Providers/SopranoProvider.cs | 63 ++++--------------- javascript/README.md | 12 +++- javascript/src/functions/providers/soprano.js | 41 +++--------- python/src/providers/soprano.py | 42 ++++--------- 4 files changed, 43 insertions(+), 115 deletions(-) diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index 537789e..dcc20af 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -2,7 +2,9 @@ namespace Epp.Otp.Providers; -// Soprano Connect (MEMS): POST {base}/messages/{sms|voice}. Auth: X-MEMS-API-ID + X-MEMS-API-Key. +// Soprano Connect (MEMS): POST {base}/messages/omnimsg. One endpoint for every channel — +// `messageTypes` picks it and Soprano does the TTS for voice. +// Auth: an Entra ID v2.0 Bearer JWT (audience = Soprano's app id), or X-MEMS-API-ID + X-MEMS-API-Key. public sealed class SopranoProvider : IProviderAdapter { public ProviderManifest Manifest { get; } = new( @@ -24,51 +26,21 @@ public sealed class SopranoProvider : IProviderAdapter public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { - var messageType = channel == "voice" ? "voice" : "sms"; var headers = new Dictionary { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }; if (credential.Mode == "oauth2") headers["Authorization"] = $"Bearer {credential.Token}"; else { headers["X-MEMS-API-ID"] = credential.Identity ?? string.Empty; headers["X-MEMS-API-Key"] = credential.Secret ?? string.Empty; } - // Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A - // non-numeric account name is sent as a free-text source instead. - object endpoints_or_source() + var body = new { - var account = env.Get("EPP_PROVIDER_ACCOUNT_NAME"); - if (!string.IsNullOrEmpty(account) && int.TryParse(account, out var sourceId)) - return new { endpoints = new[] { new { type = int.TryParse(env.Get("SOPRANO_SOURCE_TYPE"), out var parsedSourceType) ? parsedSourceType : 1, id = sourceId } } }; - return new { source = account }; - } - - var clientRef = dispatch.CorrelationId ?? dispatch.MessageId; - object body; - if (messageType == "voice") - { - var voiceLanguage = env.Get("SOPRANO_VOICE_LANGUAGE") ?? ((dispatch.Locale?.Contains('-') ?? false) ? dispatch.Locale! : "en-US"); - body = Merge(endpoints_or_source(), new - { - messageType, - destination = dispatch.Destination, - clientReference = clientRef, - voice = new - { - text2voice = new - { - beforePasswordText = dispatch.Message ?? string.Empty, - password = string.Empty, - afterPasswordText = string.Empty, - language = voiceLanguage, - gender = int.TryParse(env.Get("SOPRANO_VOICE_GENDER"), out var parsedGender) ? parsedGender : 1, - loop = 1, - }, - }, - }); - } - else - { - body = Merge(endpoints_or_source(), new { messageType, destination = dispatch.Destination, text = dispatch.Message, clientReference = clientRef }); - } - - return new ProviderHttpRequest($"{endpoint}/messages/{messageType}", "POST", headers, JsonSerializer.Serialize(body)); + text = dispatch.Message, + destination = dispatch.Destination.TrimStart('+'), // E.164 without the leading + + messageTypes = new[] { channel == "voice" ? "voice" : "sms" }, + correlationId = dispatch.CorrelationId ?? dispatch.MessageId, + // Soprano processes the request but delivers nothing — connectivity/credential testing. + shutterMode = string.Equals(env.Get("SOPRANO_SHUTTER_MODE"), "true", StringComparison.OrdinalIgnoreCase), + }; + + return new ProviderHttpRequest($"{endpoint}/messages/omnimsg", "POST", headers, JsonSerializer.Serialize(body)); } public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) @@ -88,13 +60,4 @@ public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) status ??= ok ? "SUBMITTED" : null; return new ParsedResponse(ok, httpStatus, id, status, null, desc); } - - // Shallow-merge two anonymous objects into a dictionary for JSON serialization. - private static Dictionary Merge(object first, object second) - { - var merged = new Dictionary(); - foreach (var property in first.GetType().GetProperties()) merged[property.Name] = property.GetValue(first); - foreach (var property in second.GetType().GetProperties()) merged[property.Name] = property.GetValue(second); - return merged; - } } diff --git a/javascript/README.md b/javascript/README.md index ea6925c..8adc39c 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -100,14 +100,20 @@ are all non-secret configuration; only the key/token **value** lives in Key Vaul | `SINCH_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | sender, app setting (default `Verify`) | | `SINCH_VOICE_ENDPOINT` | Sinch Voice API host, app setting (optional; default `https://calling.api.sinch.com`) | -**Soprano** +**Soprano** — posts to `{endpoint}/messages/omnimsg`, one endpoint for every channel: `messageTypes` +selects `sms` or `voice`, Soprano renders the TTS itself, and the sender comes from the account +provisioning rather than the request. + | Setting | Purpose | |---------|---------| | Key Vault secret `soprano-api-key` | API key (sent as the `X-MEMS-API-Key` header) | | Key Vault secret `soprano-api-id` | API ID (sent as the `X-MEMS-API-ID` header) | | `EPP_PROVIDER_ENDPOINT` | **required** — your MEMS API base `https:///cgpapi` (per-customer; no default) | -| `EPP_PROVIDER_ACCOUNT_NAME` | the provisioned source/sender endpoint id — Soprano requires a provisioned sender, so a **numeric** value is sent as `endpoints:[{type,id}]`; a non-numeric one falls back to a free-text `source` | -| `SOPRANO_SOURCE_TYPE` | provisioned source endpoint type, app setting (optional; default `1`) | +| `SOPRANO_SHUTTER_MODE` | `true` sends `shutterMode` so Soprano processes the request but delivers nothing, app setting (optional; default `false`) | + +> Soprano also accepts an **Entra ID v2.0** client-credentials Bearer token (audience = Soprano's app +> registration id) in place of the `X-MEMS-*` headers; the adapter sends `Authorization: Bearer` when the +> credential resolves in `oauth2` mode. > `EPP_PROVIDER_ENDPOINT` is the provider base URL for the one active provider (e.g. a sandbox host). diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index 2a79465..b249067 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -4,8 +4,9 @@ 'use strict'; -// Soprano Connect (MEMS): POST {base}/messages/{sms|voice}, base https:///cgpapi. -// Auth: X-MEMS-API-ID + X-MEMS-API-Key, or a Bearer JWT. Verified live (HTTP 201, ENROUTE). +// Soprano Connect (MEMS): POST {base}/messages/omnimsg, base https:///cgpapi. +// One endpoint for every channel — `messageTypes` picks it and Soprano does the TTS for voice. +// Auth: an Entra ID v2.0 Bearer JWT (audience = Soprano's app id), or X-MEMS-API-ID + X-MEMS-API-Key. const manifest = { id: 'soprano', @@ -29,9 +30,6 @@ const manifest = { }; function buildRequest({ channel, endpoint, dispatch, credential, env }) { - const base = endpoint; - const messageType = channel === 'voice' ? 'voice' : 'sms'; - const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }; if (credential.mode === 'oauth2') { headers.Authorization = `Bearer ${credential.token}`; @@ -41,36 +39,15 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { } const body = { - messageType, - destination: dispatch.destination, text: dispatch.message, - clientReference: dispatch.correlationId || dispatch.messageId, + destination: String(dispatch.destination || '').replace(/^\+/, ''), // E.164 without the leading + + messageTypes: [channel === 'voice' ? 'voice' : 'sms'], + correlationId: dispatch.correlationId || dispatch.messageId, + // Soprano processes the request but delivers nothing — connectivity/credential testing. + shutterMode: String(env.SOPRANO_SHUTTER_MODE || '').toLowerCase() === 'true', }; - // Soprano wants a provisioned (numeric) source endpoint; a non-numeric name goes as free-text source. - const account = env.EPP_PROVIDER_ACCOUNT_NAME; - if (account && /^\d+$/.test(account)) { - body.endpoints = [{ type: Number(env.SOPRANO_SOURCE_TYPE || 1), id: Number(account) }]; - } else if (account) { - body.source = account; - } - // `language` must be a full voice code (e.g. en-US), not a bare `en`. - if (messageType === 'voice') { - const voiceLanguage = env.SOPRANO_VOICE_LANGUAGE - || (dispatch.locale && dispatch.locale.includes('-') ? dispatch.locale : 'en-US'); - delete body.text; - body.voice = { - text2voice: { - beforePasswordText: dispatch.message || '', - password: '', - afterPasswordText: '', - language: voiceLanguage, - gender: Number(env.SOPRANO_VOICE_GENDER || 1), - loop: 1, - }, - }; - } - return { url: `${base}/messages/${messageType}`, method: 'POST', headers, body: JSON.stringify(body) }; + return { url: `${endpoint}/messages/omnimsg`, method: 'POST', headers, body: JSON.stringify(body) }; } function parseResponse({ httpStatus, ok, json }) { diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index ac44b8f..f4f42d3 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -1,5 +1,6 @@ -"""Soprano Connect (MEMS): POST {base}/messages/{sms|voice}. -Auth: X-MEMS-API-ID + X-MEMS-API-Key.""" +"""Soprano Connect (MEMS): POST {base}/messages/omnimsg. +One endpoint for every channel - `messageTypes` picks it and Soprano does the TTS for voice. +Auth: an Entra ID v2.0 Bearer JWT (audience = Soprano's app id), or X-MEMS-API-ID + X-MEMS-API-Key.""" import json @@ -19,7 +20,6 @@ class SopranoProvider: } def build_request(self, channel, endpoint, dispatch, credential, env): - message_type = "voice" if channel == "voice" else "sms" headers = {"Content-Type": "application/json", "Accept": "application/json"} if credential["mode"] == "oauth2": headers["Authorization"] = f"Bearer {credential['token']}" @@ -27,34 +27,16 @@ def build_request(self, channel, endpoint, dispatch, credential, env): headers["X-MEMS-API-ID"] = credential.get("identity") or "" headers["X-MEMS-API-Key"] = credential.get("secret") or "" - client_reference = dispatch.correlation_id or dispatch.message_id - body = {"messageType": message_type, "destination": dispatch.destination, "clientReference": client_reference} - - # Sender: a provisioned source endpoint is what Soprano accepts; free-text source is a fallback. - # Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A - # non-numeric account name is sent as a free-text source instead. - account = env.get("EPP_PROVIDER_ACCOUNT_NAME") - if account and str(account).isdigit(): - source_type = int(env.get("SOPRANO_SOURCE_TYPE") or 1) - body["endpoints"] = [{"type": source_type, "id": int(account)}] - elif account: - body["source"] = account - - if message_type == "voice": - locale = dispatch.locale or "" - voice_language = env.get("SOPRANO_VOICE_LANGUAGE") or (locale if "-" in locale else "en-US") - body["voice"] = {"text2voice": { - "beforePasswordText": dispatch.message or "", - "password": "", - "afterPasswordText": "", - "language": voice_language, - "gender": int(env.get("SOPRANO_VOICE_GENDER") or 1), - "loop": 1, - }} - else: - body["text"] = dispatch.message + body = { + "text": dispatch.message, + "destination": str(dispatch.destination or "").lstrip("+"), # E.164 without the leading + + "messageTypes": ["voice" if channel == "voice" else "sms"], + "correlationId": dispatch.correlation_id or dispatch.message_id, + # Soprano processes the request but delivers nothing - connectivity/credential testing. + "shutterMode": str(env.get("SOPRANO_SHUTTER_MODE") or "").lower() == "true", + } - return {"url": f"{endpoint}/messages/{message_type}", "method": "POST", "headers": headers, "body": json.dumps(body)} + return {"url": f"{endpoint}/messages/omnimsg", "method": "POST", "headers": headers, "body": json.dumps(body)} def parse_response(self, http_status, ok, json_body): payload = json_body[0] if isinstance(json_body, list) and json_body else json_body From c205dfbb9193289fbf8282e6f70d2b0716f1d786 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Mon, 24 Aug 2026 09:53:43 -0700 Subject: [PATCH 2/4] Soprano: map FILTERED to Fail QA4 answers 201 with status FILTERED when an account/destination filter stops delivery, on both the omnimsg and the legacy per-channel path. Pin it instead of leaning on the fail-closed default. --- dotnet/Src/Providers/SopranoProvider.cs | 2 ++ javascript/src/functions/providers/soprano.js | 2 ++ python/src/providers/soprano.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index dcc20af..3e5afce 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -18,6 +18,8 @@ public sealed class SopranoProvider : IProviderAdapter ["SENT"] = Outcome.Continue, ["DELIVERED"] = Outcome.Continue, ["QUEUED"] = Outcome.Continue, + // Accepted (HTTP 201) but stopped by an account/destination filter — nothing was delivered. + ["FILTERED"] = Outcome.Fail, ["FAILED"] = Outcome.Fail, ["REJECTED"] = Outcome.Fail, ["BLOCKED"] = Outcome.Block, diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index b249067..e5d3085 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -22,6 +22,8 @@ const manifest = { SENT: 'Continue', DELIVERED: 'Continue', QUEUED: 'Continue', + // Accepted (HTTP 201) but stopped by an account/destination filter — nothing was delivered. + FILTERED: 'Fail', FAILED: 'Fail', REJECTED: 'Fail', BLOCKED: 'Block', diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index f4f42d3..0635b36 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -15,6 +15,8 @@ class SopranoProvider: "response_mapping": { "ENROUTE": "Continue", "ACCEPTED": "Continue", "SUBMITTED": "Continue", "SENT": "Continue", "DELIVERED": "Continue", "QUEUED": "Continue", + # FILTERED: accepted (HTTP 201) but stopped by an account/destination filter - nothing delivered. + "FILTERED": "Fail", "FAILED": "Fail", "REJECTED": "Fail", "BLOCKED": "Block", "default": "Fail", }, } From ae561928ce74960dbe6abfb41fa7c9c01d86404a Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Wed, 26 Aug 2026 15:51:55 -0700 Subject: [PATCH 3/4] Soprano: address review - payload tests, null safety, shutter warning Adds an omnimsg payload test per language (endpoint, messageTypes, destination without the leading +, passcode in text) - the rewrite previously rode on suites that never asserted the new shape. Makes the .NET destination null-safe like the other two, and strips every leading + in JavaScript so all three agree. Documents SOPRANO_SHUTTER_MODE as diagnostics-only, since a 2xx with a matching nonce stops SAS falling back, and records that Soprano voice ignores locale. --- dotnet/Src/Providers/SopranoProvider.cs | 2 +- dotnet/tests/ContractTests.cs | 15 +++++++++++++++ javascript/README.md | 5 ++++- javascript/src/functions/providers/soprano.js | 2 +- javascript/test/dispatch.test.js | 18 ++++++++++++++++++ python/tests/test_contract.py | 16 ++++++++++++++++ 6 files changed, 55 insertions(+), 3 deletions(-) diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index 3e5afce..bb902ad 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -35,7 +35,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc var body = new { text = dispatch.Message, - destination = dispatch.Destination.TrimStart('+'), // E.164 without the leading + + destination = (dispatch.Destination ?? string.Empty).TrimStart('+'), // E.164 without the leading + messageTypes = new[] { channel == "voice" ? "voice" : "sms" }, correlationId = dispatch.CorrelationId ?? dispatch.MessageId, // Soprano processes the request but delivers nothing — connectivity/credential testing. diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 8447a47..68d5271 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -76,6 +76,21 @@ public void TelesignUsesBasicAuthAndVoiceMapping() Assert.Equal(Outcome.Continue, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusCode: "100"))); } + [Theory] + [InlineData("sms")] + [InlineData("voice")] + public void SopranoPostsTheOmnimsgPayload(string channel) + { + var req = new SopranoProvider().BuildRequest(channel, "https://qa.example.com/cgpapi", + Disp(channel, "code 918273"), new ProviderCredential("apiKey", Secret: "k", Identity: "id"), new FakeEnv()); + + Assert.EndsWith("/messages/omnimsg", req.Url); + using var body = JsonDocument.Parse(req.Body); + Assert.Equal(channel, body.RootElement.GetProperty("messageTypes")[0].GetString()); + Assert.Equal("15551234567", body.RootElement.GetProperty("destination").GetString()); + Assert.Contains("918273", body.RootElement.GetProperty("text").GetString()); + } + [Fact] public void ProviderRegistryResolvesById() { diff --git a/javascript/README.md b/javascript/README.md index 8adc39c..1130525 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -109,7 +109,10 @@ provisioning rather than the request. | Key Vault secret `soprano-api-key` | API key (sent as the `X-MEMS-API-Key` header) | | Key Vault secret `soprano-api-id` | API ID (sent as the `X-MEMS-API-ID` header) | | `EPP_PROVIDER_ENDPOINT` | **required** — your MEMS API base `https:///cgpapi` (per-customer; no default) | -| `SOPRANO_SHUTTER_MODE` | `true` sends `shutterMode` so Soprano processes the request but delivers nothing, app setting (optional; default `false`) | +| `SOPRANO_SHUTTER_MODE` | **diagnostics only** — `true` sends `shutterMode`, so Soprano accepts the request and delivers nothing while every layer still reports success. SAS sees a 2xx with a matching nonce and will **not** fall back, so the user gets no passcode at all. Never enable in production | + +> Soprano voice ignores `locale`: omnimsg takes no language field, so the account's default TTS voice +> is used regardless of the caller's locale. > Soprano also accepts an **Entra ID v2.0** client-credentials Bearer token (audience = Soprano's app > registration id) in place of the `X-MEMS-*` headers; the adapter sends `Authorization: Bearer` when the diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index e5d3085..c4b8b80 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -42,7 +42,7 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { const body = { text: dispatch.message, - destination: String(dispatch.destination || '').replace(/^\+/, ''), // E.164 without the leading + + destination: String(dispatch.destination || '').replace(/^\++/, ''), // E.164 without the leading + messageTypes: [channel === 'voice' ? 'voice' : 'sms'], correlationId: dispatch.correlationId || dispatch.messageId, // Soprano processes the request but delivers nothing — connectivity/credential testing. diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index fff3223..c13df0d 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -68,6 +68,24 @@ for (const prov of ['infobip', 'telesign', 'sinch', 'soprano']) { } } +// The omnimsg payload is the contract with Soprano: one endpoint, channel chosen by messageTypes. +for (const channel of ['sms', 'voice']) { + test(`soprano/${channel}: posts the omnimsg payload`, async () => { + resp = { ok: true, status: 201, body: { id: 400004307033, destination: '15551234567', status: 'ENROUTE' } }; + const r = await dispatchOtp( + disp({ channel, destination: '+15551234567' }), + { requestProvider: 'soprano', context: ctx, requestId: 'r' }, + ); + + assert.equal(r.httpStatus, 200); + assert.ok(sent.url.endsWith('/messages/omnimsg'), `unexpected url ${sent.url}`); + const body = JSON.parse(sent.opts.body); + assert.deepEqual(body.messageTypes, [channel]); + assert.equal(body.destination, '15551234567', 'E.164 must lose the leading +'); + assert.ok(body.text.includes('918273'), 'the passcode rides in text for both channels'); + }); +} + // Outcome + HTTP mapping is pure, so it is asserted directly here instead of once per case through // the whole dispatch pipeline (mirrors the .NET and Python contract tests). test('outcome mapping and HTTP status', () => { diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index db1cb31..b360028 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -1,4 +1,6 @@ """Conformance tests for the pure contract logic (see /docs/CONTRACT.md §6).""" +import json + from src.dispatch import ( BLOCK, CONTINUE, @@ -59,6 +61,20 @@ def test_telesign_basic_auth_and_voice_mapping(): assert resolve_outcome(TelesignProvider.manifest, {"success": True, "provider_status_code": "100"}) == CONTINUE +def test_soprano_posts_the_omnimsg_payload(): + for channel in ("sms", "voice"): + request = SopranoProvider().build_request( + channel, "https://qa.example.com/cgpapi", + _dispatch(channel=channel, message="code 918273"), + {"mode": "apiKey", "secret": "k", "identity": "id"}, {}, + ) + body = json.loads(request["body"]) + assert request["url"].endswith("/messages/omnimsg") + assert body["messageTypes"] == [channel] + assert body["destination"] == "15551234567" + assert "918273" in body["text"] + + def test_registry_resolves_by_id(): registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) assert registry.get("TELESIGN").manifest["id"] == "telesign" From 2954dc0ffcbf30bcb1427db56292815ca255b94e Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Wed, 26 Aug 2026 15:52:03 -0700 Subject: [PATCH 4/4] Endpoint: reject unknown envelope type and refuse an expired passcode Two deviations from the CYOT endpoint contract. The type field was parsed and logged but never validated, so a future contract version would have been interpreted as v1; it is now pinned per language. ttlSeconds <= 0 was logged and delivered anyway, which spends a message on a code that can no longer authenticate; all three now return 400 and dispatch nothing. CONTRACT.md said the old behaviour was intended, so it is corrected alongside. --- docs/CONTRACT.md | 13 +++++++------ dotnet/Functions/SendOtp.cs | 7 +++++-- dotnet/Src/DispatchEngine.cs | 8 +++++++- dotnet/tests/EnvelopeTests.cs | 15 ++++++++++++--- javascript/src/functions/SendOtp.js | 7 ++++--- javascript/src/functions/dispatch.js | 5 +++++ javascript/test/sendotp.test.js | 20 ++++++++++++++------ python/function_app.py | 6 ++++-- python/src/dispatch.py | 4 ++++ python/tests/test_envelope.py | 23 +++++++++++++++++++---- 10 files changed, 81 insertions(+), 27 deletions(-) diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 233e3f2..3ed01d4 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -32,15 +32,15 @@ JWE; the cleartext envelope carries routing/scheduling only. | Field | Required | Notes | |-------|----------|-------| -| `type` | ✅ | envelope contract version, e.g. `microsoft.mfa.otpDeliver.v1` | +| `type` | ✅ | envelope contract version, `microsoft.mfa.otpDeliver.v1`; anything else → `400` (a version we don't know may reuse these field names with different meanings) | | `tenantId` | | opaque routing guid (says nothing about the tenant) | | `correlationId` | | sign-in correlation; stitches SAS ↔ provider traces | | `channel` | ✅ | `CyotChannel` int: `1`=Sms, `2`=Voice (`0`=Undefined); the string forms `sms`/`voice` are also accepted | | `mode` | ✅ | `CyotDeliveryMode` int: `1`=Live, `2`=Evaluation (rehearsal — do **NOT** deliver); the string forms `live`/`evaluation` are also accepted | -| `ttlSeconds` | | passcode validity remaining; `<= 0` is **logged as a warning** — the delivery still proceeds | +| `ttlSeconds` | | passcode validity remaining, computed per request; `<= 0` → `400`, **nothing is delivered** — an expired passcode cannot authenticate | | `encryptedDeliveryContext` | ✅ | JWE compact serialization (see below) | -`channel` not in `{1,2}`/`{sms,voice}` → `400`. `mode` not in `{1,2}`/`{live,evaluation}` → `400`. Missing/empty `encryptedDeliveryContext` → `400`. +`type` other than `microsoft.mfa.otpDeliver.v1` → `400`. `channel` not in `{1,2}`/`{sms,voice}` → `400`. `mode` not in `{1,2}`/`{live,evaluation}` → `400`. Missing/empty `encryptedDeliveryContext` → `400`. `ttlSeconds <= 0` → `400`. ### `encryptedDeliveryContext` (JWE) @@ -118,7 +118,7 @@ Set by provisioning. **Identical names across all languages.** |-----|---------| | `EPP_PROVIDER_NAME` | active provider id (`infobip` \| `telesign` \| `sinch` \| `soprano`) | | `EPP_PROVIDER_ENDPOINT` | provider base URL (one provider is active per deployment) | -| `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider | +| `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider (unused by Soprano, whose omnimsg endpoint takes the sender from the account provisioning) | | `EPP_PROVIDER_TIMEOUT_MS` | outbound call timeout (default 1500) | | `EPP_DECRYPTION_KEY_PEM` | RSA private key for JWE decryption — PEM, or **base64 over the PEM** as the setup script writes it. A **Key Vault reference** in Azure | | `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | @@ -163,8 +163,9 @@ Every implementation ships tests covering at least: 3. Provider HTTP 200 with an **unknown** status still `Fail`s (fail-closed). 4. Missing provider credential → 502; missing endpoint config → 502. 5. Timeout → 504; network error → 502. -6. Envelope validation: `400` on invalid JSON, unsupported `channel`, unsupported `mode`, missing - `encryptedDeliveryContext`, decryption failure, and an incomplete delivery context. +6. Envelope validation: `400` on invalid JSON, an unrecognised `type`, unsupported `channel`, + unsupported `mode`, missing `encryptedDeliveryContext`, `ttlSeconds <= 0`, decryption failure, + and an incomplete delivery context. 7. JWE round-trip: a context encrypted with RSA-OAEP-256 + A256GCM decrypts to the expected `nonce` / `phoneNumber` / `message`, and the response echoes the `nonce`. 8. `Evaluation` mode → 200 + nonce echo, nothing sent. diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index c201a67..801a6fa 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -123,9 +123,12 @@ public async Task Run( correlationId = envelope.CorrelationId ?? headerCorrelationId ?? requestId; - // Surfaced rather than swallowed: the passcode expires before it can be used. + // Refused, not warned: an expired passcode can no longer authenticate. if (envelope.TtlSeconds is <= 0) - _log.LogWarning("{Tag} ttlSeconds is {Ttl}; the passcode has expired.", Tag, envelope.TtlSeconds); + { + _log.LogError("{Tag} ttlSeconds is {Ttl}; the passcode has expired. Not delivering.", Tag, envelope.TtlSeconds); + return new BadRequestObjectResult(new { error = "bad_request", reason = "passcode has expired", correlationId, requestId }); + } JweResult decrypted; try diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index ea1d6a5..3f299a8 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -22,6 +22,7 @@ public static class EnvelopeParser { public const int ModeLive = 1; public const int ModeEvaluation = 2; + public const string EnvelopeType = "microsoft.mfa.otpDeliver.v1"; private static readonly Dictionary ChannelByCode = new() { [1] = "sms", [2] = "voice" }; private static readonly Dictionary ChannelByName = new(StringComparer.OrdinalIgnoreCase) { ["sms"] = 1, ["voice"] = 2 }; @@ -54,6 +55,11 @@ public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) return name is not null && ModeByName.TryGetValue(name, out var mapped) ? mapped : null; } + // A version we don't know may reuse these field names with different meanings. + var type = String("type"); + if (type != EnvelopeType) + return (null, $"unsupported type '{type}'"); + var encrypted = String("encryptedDeliveryContext"); if (string.IsNullOrEmpty(encrypted)) return (null, "encryptedDeliveryContext is required"); @@ -66,7 +72,7 @@ public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) if (mode is null) return (null, "unsupported mode"); - return (new Envelope(String("type"), String("tenantId"), String("correlationId"), + return (new Envelope(type, String("tenantId"), String("correlationId"), channel.Value, mode.Value, Int("ttlSeconds"), encrypted), null); } } diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs index 9baede5..d0542e3 100644 --- a/dotnet/tests/EnvelopeTests.cs +++ b/dotnet/tests/EnvelopeTests.cs @@ -20,15 +20,24 @@ private sealed class FakeKeyProvider : IJweKeyProvider [Fact] public void MissingEncryptedContext_IsError() { - var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":1,\"mode\":1}")); + var (envelope, error) = EnvelopeParser.Parse(Payload("{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":1,\"mode\":1}")); Assert.Null(envelope); Assert.Contains("encryptedDeliveryContext", error); } + [Fact] + public void UnrecognisedType_IsError() + { + var (envelope, error) = EnvelopeParser.Parse(Payload( + "{\"type\":\"microsoft.mfa.otpDeliver.v2\",\"channel\":1,\"mode\":1,\"encryptedDeliveryContext\":\"x\"}")); + Assert.Null(envelope); + Assert.Contains("type", error); + } + [Fact] public void UnsupportedChannel_IsError() { - var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":9,\"mode\":1,\"encryptedDeliveryContext\":\"x\"}")); + var (envelope, error) = EnvelopeParser.Parse(Payload("{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":9,\"mode\":1,\"encryptedDeliveryContext\":\"x\"}")); Assert.Null(envelope); Assert.Contains("channel", error); } @@ -36,7 +45,7 @@ public void UnsupportedChannel_IsError() [Fact] public void UnsupportedMode_IsError() { - var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":1,\"mode\":5,\"encryptedDeliveryContext\":\"x\"}")); + var (envelope, error) = EnvelopeParser.Parse(Payload("{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":1,\"mode\":5,\"encryptedDeliveryContext\":\"x\"}")); Assert.Null(envelope); Assert.Contains("mode", error); } diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index 7d30ebc..0bdb264 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -117,9 +117,10 @@ app.http('SendOtp', { const correlationId = envelope.correlationId || headerCorrelationId || requestId; - // Surfaced rather than swallowed: the passcode expires before it can be used. - if (envelope.ttlSeconds !== undefined && envelope.ttlSeconds <= 0) { - warn(`ttlSeconds is ${envelope.ttlSeconds}; the passcode has expired.`); + // Refused, not warned: an expired passcode can no longer authenticate. + if (typeof envelope.ttlSeconds === 'number' && envelope.ttlSeconds <= 0) { + error(`ttlSeconds is ${envelope.ttlSeconds}; the passcode has expired. Not delivering.`); + return { status: 400, jsonBody: { error: 'bad_request', reason: 'passcode has expired', correlationId, requestId } }; } let header; diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index 61ba4ae..ef5f580 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -13,6 +13,7 @@ const { ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('./config'); +const ENVELOPE_TYPE = 'microsoft.mfa.otpDeliver.v1'; // CyotChannel: 1=Sms, 2=Voice. CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); @@ -36,6 +37,10 @@ function parseEnvelope(payload) { return { error: 'invalid envelope' }; } const { type, tenantId, correlationId, channel, mode, ttlSeconds, encryptedDeliveryContext } = payload; + // A version we don't know may reuse these field names with different meanings. + if (type !== ENVELOPE_TYPE) { + return { error: `unsupported type '${type}'` }; + } if (typeof encryptedDeliveryContext !== 'string' || !encryptedDeliveryContext) { return { error: 'encryptedDeliveryContext is required' }; } diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 6372200..c7b623e 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -88,11 +88,17 @@ test('SendOtp: invalid JSON body -> 400', async () => { }); test('SendOtp: missing encryptedDeliveryContext -> 400', async () => { - const r = await handlers.SendOtp(makeReq({ type: 'v1', channel: 1, mode: 1 }), ctx); + const r = await handlers.SendOtp(makeReq({ type: 'microsoft.mfa.otpDeliver.v1', channel: 1, mode: 1 }), ctx); assert.equal(r.status, 400); assert.match(r.jsonBody.reason, /encryptedDeliveryContext/); }); +test('SendOtp: unrecognised envelope type -> 400', async () => { + const r = await handlers.SendOtp(makeReq(await makeEnvelope({ type: 'microsoft.mfa.otpDeliver.v2' })), ctx); + assert.equal(r.status, 400); + assert.match(r.jsonBody.reason, /type/); +}); + test('SendOtp: unsupported channel -> 400', async () => { const r = await handlers.SendOtp(makeReq(await makeEnvelope({ channel: 9 })), ctx); assert.equal(r.status, 400); @@ -117,13 +123,15 @@ test('SendOtp: incomplete context (no phoneNumber) -> 400', async () => { assert.match(r.jsonBody.reason, /incomplete/); }); -test('SendOtp: Live with ttlSeconds <= 0 still delivers, but warns', async () => { +test('SendOtp: Live with ttlSeconds <= 0 is refused and nothing is sent', async () => { const lines = []; - const warnCtx = { log: (m) => lines.push(String(m)), warn: (m) => lines.push(String(m)), error: () => {} }; - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ ttlSeconds: 0 })), warnCtx); + const expiredCtx = { log: (m) => lines.push(String(m)), warn: (m) => lines.push(String(m)), error: (m) => lines.push(String(m)) }; + sent = undefined; + const r = await handlers.SendOtp(makeReq(await makeEnvelope({ ttlSeconds: 0 })), expiredCtx); await whenDelivered(); - assert.equal(r.status, 200); - assert.ok(lines.some((l) => /has expired/.test(l)), 'expected an expiry warning'); + assert.equal(r.status, 400); + assert.match(r.jsonBody.reason, /expired/); + assert.equal(sent, undefined, 'an expired passcode must not reach the provider'); }); test('SendOtp: valid Live envelope -> 200 accepted, nonce echoed, sent over https', async () => { diff --git a/python/function_app.py b/python/function_app.py index 4bc21af..281b79b 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -109,10 +109,12 @@ def send_otp(req: func.HttpRequest) -> func.HttpResponse: correlation_id = envelope["correlation_id"] or header_correlation_id or request_id - # Surfaced rather than swallowed: the passcode expires before it can be used. + # Refused, not warned: an expired passcode can no longer authenticate. ttl_seconds = envelope["ttl_seconds"] if isinstance(ttl_seconds, (int, float)) and not isinstance(ttl_seconds, bool) and ttl_seconds <= 0: - logging.warning("%s ttlSeconds is %s; the passcode has expired.", TAG, ttl_seconds) + logging.error("%s ttlSeconds is %s; the passcode has expired. Not delivering.", TAG, ttl_seconds) + return _json(400, {"error": "bad_request", "reason": "passcode has expired", + "correlationId": correlation_id, "requestId": request_id}) try: header, delivery = decrypt_delivery_context(envelope["encrypted_delivery_context"], _key_provider) diff --git a/python/src/dispatch.py b/python/src/dispatch.py index aca13cb..198b527 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -12,6 +12,7 @@ DEFAULT_TIMEOUT_MS = 1500 DEFAULT_CHANNELS = ["sms", "voice"] +ENVELOPE_TYPE = "microsoft.mfa.otpDeliver.v1" # Outcomes (mirrors the other languages). CONTINUE = "Continue" @@ -106,6 +107,9 @@ def parse_envelope(payload): """Returns (envelope, None) or (None, error).""" if not isinstance(payload, dict): return None, "invalid envelope" + # A version we don't know may reuse these field names with different meanings. + if payload.get("type") != ENVELOPE_TYPE: + return None, f"unsupported type '{payload.get('type')}'" encrypted = payload.get("encryptedDeliveryContext") if not isinstance(encrypted, str) or not encrypted: return None, "encryptedDeliveryContext is required" diff --git a/python/tests/test_envelope.py b/python/tests/test_envelope.py index 091c704..7078e2a 100644 --- a/python/tests/test_envelope.py +++ b/python/tests/test_envelope.py @@ -30,19 +30,31 @@ def _sample_context(): def test_missing_encrypted_context_is_error(): - envelope, error = parse_envelope({"channel": 1, "mode": 1}) + envelope, error = parse_envelope({"type": "microsoft.mfa.otpDeliver.v1", "channel": 1, "mode": 1}) assert envelope is None assert "encryptedDeliveryContext" in error +def test_unrecognised_type_is_error(): + envelope, error = parse_envelope({ + "type": "microsoft.mfa.otpDeliver.v2", "channel": 1, "mode": 1, "encryptedDeliveryContext": "x", + }) + assert envelope is None + assert "type" in error + + def test_unsupported_channel_is_error(): - envelope, error = parse_envelope({"channel": 9, "mode": 1, "encryptedDeliveryContext": "x"}) + envelope, error = parse_envelope({ + "type": "microsoft.mfa.otpDeliver.v1", "channel": 9, "mode": 1, "encryptedDeliveryContext": "x", + }) assert envelope is None assert "channel" in error def test_unsupported_mode_is_error(): - envelope, error = parse_envelope({"channel": 1, "mode": 5, "encryptedDeliveryContext": "x"}) + envelope, error = parse_envelope({ + "type": "microsoft.mfa.otpDeliver.v1", "channel": 1, "mode": 5, "encryptedDeliveryContext": "x", + }) assert envelope is None assert "mode" in error @@ -70,6 +82,7 @@ def test_jwe_round_trips_to_delivery_context(): def test_context_to_dispatch_maps_fields(): envelope, _ = parse_envelope({ + "type": "microsoft.mfa.otpDeliver.v1", "correlationId": "corr-1", "channel": 2, "mode": 1, "encryptedDeliveryContext": "x", }) dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") @@ -82,7 +95,9 @@ def test_context_to_dispatch_maps_fields(): def test_sms_message_is_left_intact(): - envelope, _ = parse_envelope({"channel": 1, "mode": 1, "encryptedDeliveryContext": "x"}) + envelope, _ = parse_envelope({ + "type": "microsoft.mfa.otpDeliver.v1", "channel": 1, "mode": 1, "encryptedDeliveryContext": "x", + }) dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") assert dispatch.message == "Your code is 123456"