Skip to content
Open
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
13 changes: 7 additions & 6 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions dotnet/Functions/SendOtp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,12 @@ public async Task<IActionResult> 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
Expand Down
8 changes: 7 additions & 1 deletion dotnet/Src/DispatchEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, string> ChannelByCode = new() { [1] = "sms", [2] = "voice" };
private static readonly Dictionary<string, int> ChannelByName = new(StringComparer.OrdinalIgnoreCase) { ["sms"] = 1, ["voice"] = 2 };
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}
}
Expand Down
63 changes: 14 additions & 49 deletions dotnet/Src/Providers/SopranoProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -16,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,
Expand All @@ -24,51 +28,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<string, string> { ["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 };
}
text = dispatch.Message,
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.
shutterMode = string.Equals(env.Get("SOPRANO_SHUTTER_MODE"), "true", StringComparison.OrdinalIgnoreCase),
};

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));
return new ProviderHttpRequest($"{endpoint}/messages/omnimsg", "POST", headers, JsonSerializer.Serialize(body));
}

public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json)
Expand All @@ -88,13 +62,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<string, object?> Merge(object first, object second)
{
var merged = new Dictionary<string, object?>();
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;
}
}
15 changes: 15 additions & 0 deletions dotnet/tests/ContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
public void TokenValidationIsSkippedUnlessRequireAuthIsTrue()
{
var env = new FakeEnv { ["EPP_REQUIRE_AUTH"] = "false" };
Assert.True(new TokenValidator(env).ValidateAsync("Bearer whatever").Result.Ok);

Check warning on line 33 in dotnet/tests/ContractTests.cs

View workflow job for this annotation

GitHub Actions / C# (.NET isolated)

Test methods should not use blocking task operations, as they can cause deadlocks. Use an async test method and await instead. (https://xunit.net/xunit.analyzers/rules/xUnit1031)
}

[Fact]
Expand Down Expand Up @@ -76,6 +76,21 @@
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()
{
Expand Down
15 changes: 12 additions & 3 deletions dotnet/tests/EnvelopeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,32 @@ 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);
}

[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);
}
Expand Down
15 changes: 12 additions & 3 deletions javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,23 @@ 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://<your-mems-domain>/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` | **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
> credential resolves in `oauth2` mode.

> `EPP_PROVIDER_ENDPOINT` is the provider base URL for the one active provider (e.g. a sandbox host).

Expand Down
7 changes: 4 additions & 3 deletions javascript/src/functions/SendOtp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions javascript/src/functions/dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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' };
}
Expand Down
Loading
Loading