Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 11 additions & 20 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,29 +76,21 @@ runs once per language. Tag tampering and original-header-byte tests remain.
|-------|----------|-------|
| `nonce` | yes | value the endpoint MUST echo to prove decryption |
| `phoneNumber` | yes | caller supplies an E.164 string; full E.164 validation is an implementation gap |
| `message` | yes | fully rendered, localized text containing the passcode; forward unchanged when the adapter uses message text. Do not extract, infer or guess a passcode |
| `message` | yes | fully rendered, localized text containing the passcode; text-message adapters forward it unchanged, while Soprano voice extracts the first six consecutive digits |
| `extension` | no | office-voice contract field; not currently forwarded by the shared dispatch model |
| `locale` | no | voice selection input where supported by the selected adapter |
| `riskContext` | no | contextual request data; no risk-policy evaluation is implemented here |
| `textToVoice` | for Soprano live voice | structured speech object supplied inside the encrypted context; see below |
| `textToVoice` | no | legacy structured speech input; current Soprano adapters ignore it |

Decryption failure → `400`. Missing `nonce` / `phoneNumber` / `message` → `400`.

For Soprano live voice, include `textToVoice` alongside the required delivery fields:

```json
"textToVoice": {
"beforePasswordText": "Your verification code is",
"password": "001234",
"language": "en-US"
}
```

`beforePasswordText` must be a string (empty is allowed); `password` and `language` must be
nonblank strings. Supply the password explicitly to preserve leading zeros; it is never extracted
from `message`. These values are forwarded unchanged as `voice.text2voice`, without a top-level
`text` field. Missing or invalid speech returns `400` before credential lookup or provider HTTP.
SMS continues to use `message`, and evaluation continues to skip provider-specific validation and I/O.
For Soprano live voice, the adapter finds the first six consecutive digits in `message`, preserving
leading zeros, and splits the rendered text into `beforePasswordText`, `password`, and
`afterPasswordText`. It uses a nonblank SAS `locale` as `language`, falling back to `en-US`, and sends
fixed `gender: 1` and `loop: 2`. The resulting object is sent as `voice.text2voice` without a top-level
`text` field. A message without a six-digit sequence fails closed before provider HTTP. No additional
environment settings are required. Soprano SMS continues to forward `message` unchanged, and
evaluation continues to skip provider-specific validation and I/O.
Soprano uses OAuth client-assertion exchange. The outbound user-assigned managed identity obtains an
`api://AzureADTokenExchange/.default` assertion for the existing multitenant application, which then
requests the configured provider scope. Existing platform caller authentication is unchanged.
Expand Down Expand Up @@ -139,11 +131,10 @@ plus JWT do not validate the current Bearer-only configuration or production end

See [Microsoft's managed-identity federation guidance](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-config-app-trust-managed-identity).

Use a speech language supported by the selected Soprano endpoint and account. On QA4, an API-key
Use a SAS locale supported by the selected Soprano endpoint and account. On QA4, an API-key
voice request using `en` returned HTTP `400` with error code `400101`; the same request structure
using `en-US` returned HTTP `201` with `ENROUTE` on September 15, 2026. This confirms acceptance,
not handset receipt or audio quality. The adapter preserves the supplied language and does not
guess a region for a language-only value.
not handset receipt or audio quality. When SAS omits the locale, the adapters use `en-US`.

The supplied Soprano Connect Voice PDF describes a different API: `POST /voice/voice_orderApiCreate.do`
with form-encoded fields, `subAction=20`, and numeric language IDs (`1` is default English).
Expand Down
5 changes: 5 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ await acceptance before returning the nonce; failures omit it. Acceptance is not
Platform/key prerequisites and HTTP outcomes are defined in the
[contract](../docs/CONTRACT.md#evaluation-generic-shutter).

For Soprano voice, the adapter extracts the first six-digit passcode from the rendered message. It
uses a nonblank SAS request locale as the language, falling back to `en-US`, and sends fixed gender
`1` and loop `2`. These values require no additional environment settings. Soprano SMS continues to
forward the rendered message unchanged.

## Source

| Source | Purpose |
Expand Down
27 changes: 22 additions & 5 deletions dotnet/Src/Providers/SopranoProvider.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Epp.Otp.Providers;

public sealed class SopranoProvider : IProviderAdapter
{
private const string DefaultVoiceLanguage = "en-US";
private const int VoiceGender = 1;
private const int VoiceLoop = 2;

public ProviderManifest Manifest { get; } = new(
Id: "soprano",
Auth: new AuthConfig("oauth"),
Expand All @@ -20,8 +25,7 @@ public sealed class SopranoProvider : IProviderAdapter
["FILTERED"] = Outcome.Fail,
["BLOCKED"] = Outcome.Block,
["default"] = Outcome.Fail,
},
RequiresTextToVoice: true);
});

public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env)
{
Expand All @@ -40,9 +44,22 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc
};
if (channel == "voice")
{
var voice = dispatch.TextToVoice;
if (voice?.IsComplete != true) throw new InvalidOperationException("incomplete voice context");
body["voice"] = new { text2voice = voice };
var message = dispatch.Message ?? string.Empty;
var passcode = Regex.Match(message, "[0-9]{6}");
if (!passcode.Success)
throw new InvalidOperationException("voice message does not contain a six-digit passcode");
body["voice"] = new
{
text2voice = new
{
beforePasswordText = message[..passcode.Index],
password = passcode.Value,
afterPasswordText = message[(passcode.Index + passcode.Length)..],
language = string.IsNullOrWhiteSpace(dispatch.Locale) ? DefaultVoiceLanguage : dispatch.Locale,
gender = VoiceGender,
loop = VoiceLoop,
},
};
}
else
{
Expand Down
43 changes: 41 additions & 2 deletions dotnet/tests/ContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ private static DispatchRequest Request(string channel = "sms") =>
[InlineData("voice")]
public void SopranoUsesSelectedEndpointAndOAuth(string channel)
{
var dispatch = Request(channel) with { TextToVoice = new TextToVoice("Your code is", "001234", "en-US") };
var dispatch = Request(channel) with
{
Locale = channel == "voice" ? "fr-FR" : "en-US",
TextToVoice = new TextToVoice("ignored", "001234", "override"),
};
var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/oauth/messages", dispatch,
new ProviderCredential("oauth", AccessToken: "provider-token"), new TestEnv());
Assert.Equal("https://provider.example/oauth/messages", request.Url);
Expand All @@ -32,12 +36,47 @@ public void SopranoUsesSelectedEndpointAndOAuth(string channel)
["shutterMode"] = false,
};
if (channel == "voice")
expected["voice"] = new { text2voice = new { beforePasswordText = "Your code is", password = "001234", language = "en-US" } };
expected["voice"] = new
{
text2voice = new
{
beforePasswordText = " Your code is ",
password = "918273",
afterPasswordText = ".\nDo not share. ",
language = "fr-FR",
gender = 1,
loop = 2,
},
};
else
expected["text"] = Request().Message;
Assert.Equal(JsonSerializer.Serialize(expected), request.Body);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void SopranoVoiceDefaultsLanguageWithoutLocale(string? locale)
{
var request = new SopranoProvider().BuildRequest("voice", "https://provider.example/oauth/messages",
Request("voice") with { Locale = locale }, new ProviderCredential("oauth", AccessToken: "provider-token"),
new TestEnv());
using var body = JsonDocument.Parse(request.Body);
Assert.Equal("en-US", body.RootElement.GetProperty("voice").GetProperty("text2voice")
.GetProperty("language").GetString());
}

[Fact]
public void SopranoVoiceRequiresSixDigitPasscode()
{
var dispatch = Request("voice") with { Message = "Your code is unavailable." };
var error = Assert.Throws<InvalidOperationException>(() => new SopranoProvider().BuildRequest(
"voice", "https://provider.example/oauth/messages", dispatch,
new ProviderCredential("oauth", AccessToken: "provider-token"), new TestEnv()));
Assert.Contains("six-digit passcode", error.Message);
}

[Fact]
public void ProviderStatusesMapToExpectedOutcomesAndHttpCodes()
{
Expand Down
31 changes: 13 additions & 18 deletions dotnet/tests/EngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,24 @@ public async Task SopranoOAuthUsesSetupIdentitiesScopeAndOneBoundedExchange()
rig.Env["EPP_PROVIDER_CHANNEL"] = channel;
AssertAccepted(await rig.Invoke(channel: channel, deliveryOverrides: JsonSerializer.SerializeToElement(new
{
providerJwt = "FORGED-PAYLOAD", textToVoice = new { beforePasswordText = "Code", password = "001234", language = "en-US" },
providerJwt = "FORGED-PAYLOAD", locale = "fr-FR",
textToVoice = new { beforePasswordText = "ignored", password = "001234", language = "override" },
})));
Assert.Equal("Bearer private-provider-token", rig.Http.Headers["Authorization"]);
Assert.DoesNotContain("X-MEMS-API-ID", rig.Http.Headers.Keys);
Assert.DoesNotContain("X-MEMS-API-Key", rig.Http.Headers.Keys);
Assert.DoesNotContain("FORGED", rig.Http.Body!);
if (channel == "voice")
{
using var body = JsonDocument.Parse(rig.Http.Body!);
var speech = body.RootElement.GetProperty("voice").GetProperty("text2voice");
Assert.Equal(" Your code is ", speech.GetProperty("beforePasswordText").GetString());
Assert.Equal("918273", speech.GetProperty("password").GetString());
Assert.Equal(".\nDo not share. ", speech.GetProperty("afterPasswordText").GetString());
Assert.Equal("fr-FR", speech.GetProperty("language").GetString());
Assert.Equal(1, speech.GetProperty("gender").GetInt32());
Assert.Equal(2, speech.GetProperty("loop").GetInt32());
}
}
rig.Env["EPP_PROVIDER_CHANNEL"] = "sms";
rig.Env["EPP_PROVIDER_SCOPE"] = "api://second/.default";
Expand Down Expand Up @@ -181,23 +193,6 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate()
Assert.DoesNotContain(value, log);
}

[Theory]
[InlineData("null")]
[InlineData("[]")]
[InlineData("{}")]
[InlineData("{\"beforePasswordText\":\"\",\"password\":123,\"language\":\"en\"}")]
[InlineData("{\"beforePasswordText\":null,\"password\":\"001234\",\"language\":\"en\"}")]
[InlineData("{\"beforePasswordText\":\"\",\"password\":\"001234\",\"language\":\" \"}")]
public async Task IncompleteVoiceFailsBeforeSecretsOrHttp(string speech)
{
using var rig = new HandlerRig();
rig.Env["EPP_PROVIDER_NAME"] = "soprano";
rig.Env["EPP_PROVIDER_AUTH_MODE"] = "oauth";
var overrides = JsonSerializer.SerializeToElement(new { textToVoice = JsonSerializer.Deserialize<JsonElement>(speech) });
AssertFailure(rig, await rig.Invoke(channel: "voice", deliveryOverrides: overrides), 400);
Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls));
}

[Fact]
public void VoiceAllowsEmptyIntroAndKeepsDebugOutputPrivate()
{
Expand Down
8 changes: 6 additions & 2 deletions javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,12 @@ configure the current shared engine. Use `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDP
that are actually read, such as a service-plan ID or voice selection. Private integration helpers may
load settings from another location or use test credential variables, but the Function itself does not.

The Soprano adapter uses the configured complete endpoint and an OAuth bearer token. The Telesign
adapter uses the configured complete endpoint and API-key credentials from Key Vault.
The Soprano adapter uses the configured complete endpoint and an OAuth bearer token. For voice, it
extracts the first six-digit passcode from the rendered message and sends fixed synthesis values:
gender `1` and loop `2`. It uses a nonblank SAS request locale as the language, falling back to
`en-US` when the locale is absent or invalid. These values require no additional environment
settings. Soprano SMS continues to forward the rendered message unchanged. The Telesign adapter
uses the configured complete endpoint and API-key credentials from Key Vault.

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
Expand Down
29 changes: 24 additions & 5 deletions javascript/src/functions/providers/soprano.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

'use strict';

const { ParsedResponse, TextToVoice } = require('../models');
const { ParsedResponse } = require('../models');

const DEFAULT_VOICE_LANGUAGE = 'en-US';
const VOICE_GENDER = 1;
const VOICE_LOOP = 2;

const manifest = {
id: 'soprano',
requiresTextToVoice: true,
auth: { mode: 'oauth' },
responseMapping: {
ENROUTE: 'Continue',
Expand All @@ -25,6 +28,24 @@ const manifest = {
},
};

function buildTextToVoice(message, locale) {
const renderedMessage = String(message || '');
const passcodeMatch = renderedMessage.match(/\d{6}/);
if (!passcodeMatch) {
throw new Error('voice message does not contain a six-digit passcode');
}

const passcodeIndex = passcodeMatch.index;
return {
beforePasswordText: renderedMessage.slice(0, passcodeIndex),
password: passcodeMatch[0],
afterPasswordText: renderedMessage.slice(passcodeIndex + passcodeMatch[0].length),
language: typeof locale === 'string' && locale.trim() ? locale : DEFAULT_VOICE_LANGUAGE,
gender: VOICE_GENDER,
loop: VOICE_LOOP,
};
}

function buildRequest({ channel, endpoint, dispatch, credential }) {
const headers = {
'Content-Type': 'application/json',
Expand All @@ -40,9 +61,7 @@ function buildRequest({ channel, endpoint, dispatch, credential }) {
shutterMode: false,
};
if (channel === 'voice') {
const voice = dispatch.textToVoice;
if (!(voice instanceof TextToVoice) || !voice.isComplete) throw new Error('incomplete voice context');
body.voice = { text2voice: voice };
body.voice = { text2voice: buildTextToVoice(dispatch.message, dispatch.locale) };
} else {
body.text = dispatch.message;
}
Expand Down
Loading
Loading