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
9 changes: 9 additions & 0 deletions docs/local.settings.sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
"EPP_PROVIDER_ACCOUNT_NAME": "<your sender / source id>",
"EPP_PROVIDER_TIMEOUT_MS": "1500",

"_comment_provider_oauth": "Set EPP_PROVIDER_AUTH_MODE=oauth2 to send the provider an app-only Entra JWT we mint (client-credentials) instead of an API key. Issuer is our app (EPP_PROVIDER_CLIENT_ID), authority is the provider's tenant (EPP_PROVIDER_TENANT_ID), audience is the provider's app via EPP_PROVIDER_SCOPE. Set EPP_PROVIDER_MI_CLIENT_ID for secretless workload-identity federation (a user-assigned managed identity federated on our app), OR provide the client secret as a Key Vault secret name (EPP_PROVIDER_CLIENT_SECRET_NAME).",
"EPP_PROVIDER_AUTH_MODE": "apiKey",
"EPP_PROVIDER_TENANT_ID": "<provider tenant id>",
"EPP_PROVIDER_CLIENT_ID": "<our app registration client id>",
"EPP_PROVIDER_SCOPE": "<provider-app-id>/.default",
"EPP_PROVIDER_MI_CLIENT_ID": "<user-assigned managed identity client id (federated on our app)>",
"EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE": "api://AzureADTokenExchange",
"EPP_PROVIDER_CLIENT_SECRET_NAME": "<Key Vault secret name holding our client secret (only if not using a managed identity)>",

"_comment_log_plaintext": "DIAGNOSTICS ONLY. true writes the phone number and passcode to the log. Never enable in production.",
"EPP_LOG_PLAINTEXT": "false",

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
1 change: 1 addition & 0 deletions dotnet/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
builder.Services.AddHttpClient();
builder.Services.AddSingleton<IEnv, ProcessEnv>();
builder.Services.AddSingleton<ISecretResolver, SecretResolver>();
builder.Services.AddSingleton<IProviderTokenAcquirer, ProviderTokenAcquirer>();
builder.Services.AddSingleton<TokenValidator>();
builder.Services.AddSingleton<IJweKeyProvider, EnvJweKeyProvider>();
builder.Services.AddSingleton<JweDecryptor>();
Expand Down
20 changes: 17 additions & 3 deletions 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 Expand Up @@ -163,13 +169,15 @@ public sealed class DispatchEngine
private readonly ISecretResolver _secrets;
private readonly IHttpClientFactory _httpFactory;
private readonly IEnv _env;
private readonly IProviderTokenAcquirer _tokenAcquirer;

public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null)
public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null, IProviderTokenAcquirer? tokenAcquirer = null)
{
_registry = registry;
_secrets = secrets;
_httpFactory = httpFactory;
_env = env ?? new ProcessEnv();
_tokenAcquirer = tokenAcquirer ?? new ProviderTokenAcquirer(_env, _secrets);
}

public async Task<DispatchResult> DispatchAsync(DispatchRequest dispatch, string? requestProvider, bool shutter, string requestId, ILogger log)
Expand Down Expand Up @@ -257,7 +265,13 @@ public async Task<DispatchResult> DispatchAsync(DispatchRequest dispatch, string

private async Task<ProviderCredential> ResolveCredentialAsync(AuthConfig auth)
{
if (auth.Mode == "oauth2") return new ProviderCredential("oauth2", Token: null); // not wired -> fails closed
var mode = _env.Get("EPP_PROVIDER_AUTH_MODE");
if (string.IsNullOrEmpty(mode)) mode = auth.Mode;
if (string.Equals(mode, "oauth2", StringComparison.OrdinalIgnoreCase))
{
var token = await _tokenAcquirer.AcquireAsync();
return new ProviderCredential("oauth2", Token: token);
}
var secret = await _secrets.ResolveAsync(auth.KeyVaultSecretName);
var identity = string.IsNullOrEmpty(auth.IdentityKeyVaultSecretName) ? string.Empty : await _secrets.ResolveAsync(auth.IdentityKeyVaultSecretName);
return new ProviderCredential("apiKey", Secret: secret, Identity: identity);
Expand Down
73 changes: 73 additions & 0 deletions dotnet/Src/ProviderTokenAcquirer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Azure.Core;
using Azure.Identity;

namespace Epp.Otp;

// oauth2 provider auth: mint our own app-only Entra JWT (client-credentials) for the provider's API
// and send it as a Bearer token. Issuer is our app; audience is the provider's app (the scope). Never
// the caller's inbound token. Injectable so tests don't reach Entra.
public interface IProviderTokenAcquirer
{
Task<string> AcquireAsync(CancellationToken cancellationToken = default);
}

public sealed class ProviderTokenAcquirer : IProviderTokenAcquirer
{
private static readonly TimeSpan ExpirySkew = TimeSpan.FromMinutes(5);
private readonly IEnv _env;
private readonly ISecretResolver _secrets;
private (string Token, DateTimeOffset Expires, string Key)? _cache;

public ProviderTokenAcquirer(IEnv env, ISecretResolver secrets)
{
_env = env;
_secrets = secrets;
}

public async Task<string> AcquireAsync(CancellationToken cancellationToken = default)
{
var tenantId = _env.Get("EPP_PROVIDER_TENANT_ID");
var clientId = _env.Get("EPP_PROVIDER_CLIENT_ID");
var scope = _env.Get("EPP_PROVIDER_SCOPE");
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(scope))
throw new InvalidOperationException("oauth2 requires EPP_PROVIDER_TENANT_ID, EPP_PROVIDER_CLIENT_ID and EPP_PROVIDER_SCOPE");

var cacheKey = $"{tenantId}|{clientId}|{scope}";
if (_cache is { } cached && cached.Key == cacheKey && cached.Expires - ExpirySkew > DateTimeOffset.UtcNow)
return cached.Token;

var credential = await BuildCredentialAsync(tenantId, clientId);
var token = await credential.GetTokenAsync(new TokenRequestContext(new[] { scope }), cancellationToken);
_cache = (token.Token, token.ExpiresOn, cacheKey);
return token.Token;
}

// Managed-identity federation (selected by EPP_PROVIDER_MI_CLIENT_ID) keeps the cross-tenant call
// secretless; otherwise use a client secret from Key Vault (or an env var for local runs).
private async Task<TokenCredential> BuildCredentialAsync(string tenantId, string clientId)
{
var managedIdentityClientId = _env.Get("EPP_PROVIDER_MI_CLIENT_ID");
if (!string.IsNullOrEmpty(managedIdentityClientId))
{
var managedIdentity = new ManagedIdentityCredential(managedIdentityClientId);
var audience = _env.Get("EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE") ?? "api://AzureADTokenExchange";
var exchangeScope = audience.EndsWith("/.default", StringComparison.Ordinal) ? audience : $"{audience}/.default";
return new ClientAssertionCredential(tenantId, clientId, async ct =>
{
var assertion = await managedIdentity.GetTokenAsync(
new TokenRequestContext(new[] { exchangeScope }), ct);
return assertion.Token;
});
}

var secret = _env.Get("EPP_PROVIDER_CLIENT_SECRET");
if (string.IsNullOrEmpty(secret))
{
var secretName = _env.Get("EPP_PROVIDER_CLIENT_SECRET_NAME");
secret = string.IsNullOrEmpty(secretName) ? string.Empty : await _secrets.ResolveAsync(secretName);
}
if (string.IsNullOrEmpty(secret))
throw new InvalidOperationException("oauth2 requires EPP_PROVIDER_MI_CLIENT_ID (managed identity) or EPP_PROVIDER_CLIENT_SECRET_NAME");
return new ClientSecretCredential(tenantId, clientId, secret);
}
}
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
Loading
Loading