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
38 changes: 37 additions & 1 deletion docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,43 @@ Soprano uses OAuth client-assertion exchange. The outbound user-assigned managed
`api://AzureADTokenExchange/.default` assertion for the existing multitenant application, which then
requests the configured provider scope. Existing platform caller authentication is unchanged.

Use a speech language supported by the selected Soprano endpoint and account. On QA4, an OAuth
### Soprano provider JWT

The Function obtains one provider token through the shared OAuth credential resolver using the
setup-generated `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE`, `EPP_OUTBOUND_CLIENT_ID`, and
`EPP_OUTBOUND_MI_CLIENT_ID`. `EPP_PROVIDER_AUTH_MODE=oauth` matches the Soprano adapter.
`EPP_PROVIDER_ENDPOINT` is the complete selected send URL and is not modified by the adapter.
The calling app registration and outbound user-assigned identity must share a home tenant; the
calling app must be multitenant and provisioned/authorized in the provider tenant. The app's
federated credential trusts the identity's principal ID, home-tenant v2 issuer, and
`api://AzureADTokenExchange` audience. Key Vault identity selection remains independent.

Only the final application token is sent as `Authorization: Bearer ...`. No Soprano API ID/key,
managed-identity assertion, or incoming SAS token is forwarded. Missing settings, token-acquisition
failure, blank tokens, or tokens with 30 seconds or less remaining lifetime fail before provider HTTP;
there is no API-key fallback. Evaluation skips acquisition. A provider rejection is not retried.
Tokens are treated as opaque: the Function checks SDK expiry metadata, not custom JWT claims.
Soprano remains responsible for signature, issuer, audience, expiry, permissions, and account validation.

Credential instances are reused for the configured tenant/application/identity; each acquisition
uses the selected scope. JavaScript and .NET pass one 2.5-second cancellation signal/token through
both exchange stages. Python uses 2.5-second connect/read inactivity timeouts, not a total deadline.
Configured SDK transport retries are disabled. Managed-identity discovery may involve additional
SDK operations; this is not an end-to-end delivery deadline. JavaScript suppresses SDK logs only
in the acquisition's asynchronous context. Python filters Azure Identity/Core/MSAL records on
configured handlers in that context; configure logging sinks before handling requests. .NET disables
credential diagnostics. Keep platform body tracing off and never log credential objects or tokens.

When migrating from the earlier optional-JWT branch, replace `EPP_PROVIDER_APPLICATION_ID` with
`EPP_OUTBOUND_CLIENT_ID` and `EPP_PROVIDER_MI_CLIENT_ID` with `EPP_OUTBOUND_MI_CLIENT_ID`.
Remove `EPP_PROVIDER_JWT_ENABLED`; it no longer controls authentication. Reuse the exact provider
scope selected by setup and replace old base URLs with complete send URLs. These source changes
do not update deployed settings or establish provider authorization. Earlier QA4 tests of API keys
plus JWT do not validate the current Bearer-only configuration or production endpoints.

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
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
Expand Down
35 changes: 32 additions & 3 deletions dotnet/Src/DispatchEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,15 +221,39 @@ public sealed class DispatchEngine
private readonly object _oauthLock = new();
private TokenCredential? _oauthCredential;
private string? _oauthCredentialConfig;
private readonly Func<string, TokenCredential> _createManagedIdentity;
private readonly Func<string, string, Func<CancellationToken, Task<string>>, TokenCredential> _createOAuthCredential;

public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null)
: this(registry, secrets, httpFactory, env,
identity => new ManagedIdentityCredential(identity, OAuthOptions()),
(tenant, application, assertion) => new ClientAssertionCredential(tenant, application, assertion, OAuthOptions())) { }

internal DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env,
Func<string, TokenCredential> createManagedIdentity,
Func<string, string, Func<CancellationToken, Task<string>>, TokenCredential> createOAuthCredential)
{
_registry = registry;
_secrets = secrets;
_httpFactory = httpFactory;
_env = env ?? new ProcessEnv();
_createManagedIdentity = createManagedIdentity;
_createOAuthCredential = createOAuthCredential;
}

private static ClientAssertionCredentialOptions OAuthOptions()
{
var options = new ClientAssertionCredentialOptions { AuthorityHost = AzureAuthorityHosts.AzurePublicCloud };
options.Retry.MaxRetries = 0;
options.Retry.NetworkTimeout = TimeSpan.FromSeconds(2.5);
options.Diagnostics.IsLoggingEnabled = false;
options.Diagnostics.IsLoggingContentEnabled = false;
return options;
}

private static bool UsableAccessToken(AccessToken token) =>
!string.IsNullOrWhiteSpace(token.Token) && token.ExpiresOn > DateTimeOffset.UtcNow.AddSeconds(30);

public async Task<DispatchResult> DispatchAsync(DispatchRequest dispatch, string requestId)
{
var config = AppConfig.Read(_env);
Expand Down Expand Up @@ -327,24 +351,29 @@ private async Task<ProviderCredential> ResolveCredentialAsync(AuthConfig auth, A
{
if (_oauthCredential is null || _oauthCredentialConfig != credentialConfig)
{
var managedIdentity = new ManagedIdentityCredential(config.OutboundManagedIdentityClientId);
_oauthCredential = new ClientAssertionCredential(
var managedIdentity = _createManagedIdentity(config.OutboundManagedIdentityClientId);
_oauthCredential = _createOAuthCredential(
config.ProviderTenantId,
config.OutboundClientId,
async cancellationToken =>
{
var assertion = await managedIdentity.GetTokenAsync(
new TokenRequestContext(new[] { "api://AzureADTokenExchange/.default" }),
cancellationToken);
if (!UsableAccessToken(assertion))
throw new InvalidOperationException("managed identity assertion unavailable");
return assertion.Token;
});
_oauthCredentialConfig = credentialConfig;
}
providerCredential = _oauthCredential;
}
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2.5));
var token = await providerCredential.GetTokenAsync(
new TokenRequestContext(new[] { config.ProviderScope }),
CancellationToken.None);
cancellation.Token);
if (!UsableAccessToken(token))
throw new InvalidOperationException("provider OAuth token unavailable");
return new ProviderCredential("oauth", AccessToken: token.Token);
}

Expand Down
6 changes: 5 additions & 1 deletion dotnet/Src/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ public sealed record TextToVoice(
public override string ToString() => nameof(TextToVoice);
}

public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, string? AccessToken = null);
public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null,
[property: JsonIgnore] string? AccessToken = null)
{
public override string ToString() => nameof(ProviderCredential);
}

public sealed record ProviderHttpRequest(string Url, string Method, Dictionary<string, string> Headers, string Body);

Expand Down
136 changes: 134 additions & 2 deletions dotnet/tests/EngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Azure.Core;
using Epp.Otp.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -19,6 +20,134 @@ public class EngineTests
private const string Correlation = "private-correlation";
private const string PrivateError = "private key/provider error: +15551234567 code 918273";

private static void ConfigureSoprano(HandlerRig rig)
{
rig.Env["EPP_PROVIDER_NAME"] = "soprano";
rig.Env["EPP_PROVIDER_AUTH_MODE"] = "oauth";
rig.Env["EPP_PROVIDER_CHANNEL"] = "sms";
rig.Env["EPP_PROVIDER_ENDPOINT"] = "https://provider.example/full/send/";
rig.Env["EPP_PROVIDER_TENANT_ID"] = "provider-tenant";
rig.Env["EPP_PROVIDER_SCOPE"] = "api://provider/.default";
rig.Env["EPP_OUTBOUND_CLIENT_ID"] = "calling-application";
rig.Env["EPP_OUTBOUND_MI_CLIENT_ID"] = "outbound-identity";
rig.Env["AZURE_CLIENT_ID"] = "different-vault-identity";
rig.Http.Respond = _ => Task.FromResult(Json(201, "{\"status\":\"ENROUTE\"}"));
}

[Fact]
public async Task SopranoOAuthUsesSetupIdentitiesScopeAndOneBoundedExchange()
{
var scopes = new List<string>();
var identities = new List<string>();
var applications = new List<(string Tenant, string Application)>();
CancellationToken outerCancellation = default;
using var rig = new HandlerRig(identity =>
{
identities.Add(identity);
return new TestTokenCredential((context, cancellation) =>
{
Assert.Equal("api://AzureADTokenExchange/.default", Assert.Single(context.Scopes));
Assert.Equal(outerCancellation, cancellation);
return ValueTask.FromResult(new AccessToken("private-assertion", DateTimeOffset.UtcNow.AddHours(1)));
});
}, (tenant, application, assertion) =>
{
applications.Add((tenant, application));
return new TestTokenCredential(async (context, cancellation) =>
{
Assert.True(cancellation.CanBeCanceled);
outerCancellation = cancellation;
scopes.Add(Assert.Single(context.Scopes));
Assert.Equal("private-assertion", await assertion(cancellation));
return new AccessToken("private-provider-token", DateTimeOffset.UtcNow.AddHours(1));
});
});
ConfigureSoprano(rig);
AssertAccepted(await rig.Invoke("evaluation"));
Assert.Empty(applications);
foreach (var channel in new[] { "sms", "voice" })
{
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" },
})));
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!);
}
rig.Env["EPP_PROVIDER_CHANNEL"] = "sms";
rig.Env["EPP_PROVIDER_SCOPE"] = "api://second/.default";
AssertAccepted(await rig.Invoke());
Assert.Single(applications);
Assert.Equal(new[] { "api://provider/.default", "api://provider/.default", "api://second/.default" }, scopes);
rig.Env["EPP_OUTBOUND_CLIENT_ID"] = "second-application";
AssertAccepted(await rig.Invoke());
Assert.Equal(new[] { ("provider-tenant", "calling-application"), ("provider-tenant", "second-application") }, applications);
Assert.All(identities, identity => Assert.Equal("outbound-identity", identity));
Assert.Equal(4, rig.Http.Calls);
Assert.Equal(0, rig.Secrets.Calls);
Assert.DoesNotContain("private-provider-token", string.Join("\n", rig.Log.Messages));
var credential = new ProviderCredential("oauth", AccessToken: "private-provider-token");
Assert.DoesNotContain("private-provider-token", JsonSerializer.Serialize(credential) + credential);
}

[Theory]
[InlineData(true, "", 3600)]
[InlineData(true, "private-assertion", 5)]
[InlineData(false, " ", 3600)]
[InlineData(false, "private-token", -1)]
public async Task SopranoOAuthRejectsUnusableTokensBeforeProviderIo(bool invalidAssertion, string token, int lifetime)
{
var invalid = new AccessToken(token, DateTimeOffset.UtcNow.AddSeconds(lifetime));
using var rig = new HandlerRig(_ => new TestTokenCredential((_, _) => ValueTask.FromResult(invalidAssertion
? invalid : new AccessToken("assertion", DateTimeOffset.UtcNow.AddHours(1)))),
(_, _, assertion) => new TestTokenCredential(async (_, cancellation) =>
{
await assertion(cancellation);
return invalid;
}));
ConfigureSoprano(rig);
AssertFailure(rig, await rig.Invoke(), 502);
Assert.Equal((0, 0), (rig.Http.Calls, rig.Secrets.Calls));
}

[Fact]
public async Task SopranoOAuthCancellationAndRejectionNeverFallBackOrRetry()
{
CancellationToken observed = default;
var waitForCancellation = true;
using var rig = new HandlerRig(_ => new TestTokenCredential(async (_, cancellation) =>
{
if (waitForCancellation)
{
observed = cancellation;
await Task.Delay(Timeout.Infinite, cancellation);
}
return new AccessToken("assertion", DateTimeOffset.UtcNow.AddHours(1));
}), (_, _, assertion) => new TestTokenCredential(async (_, cancellation) =>
{
await assertion(cancellation);
return new AccessToken("token", DateTimeOffset.UtcNow.AddHours(1));
}));
ConfigureSoprano(rig);
AssertFailure(rig, await rig.Invoke().WaitAsync(TimeSpan.FromSeconds(10)), 502);
Assert.True(observed.IsCancellationRequested);
Assert.Equal((0, 0), (rig.Http.Calls, rig.Secrets.Calls));
waitForCancellation = false;
rig.Http.Respond = _ => Task.FromResult(Json(401, "{\"status\":\"REJECTED\"}"));
AssertFailure(rig, await rig.Invoke(), 401);
Assert.Equal((1, 0), (rig.Http.Calls, rig.Secrets.Calls));
}

private sealed class TestTokenCredential(Func<TokenRequestContext, CancellationToken, ValueTask<AccessToken>> acquire) : TokenCredential
{
public override AccessToken GetToken(TokenRequestContext context, CancellationToken cancellation) =>
throw new InvalidOperationException("Synchronous acquisition not expected");
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext context, CancellationToken cancellation) => acquire(context, cancellation);
}

[Fact]
public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate()
{
Expand Down Expand Up @@ -264,7 +393,8 @@ private sealed class HandlerRig : IDisposable
public TestHttp Http { get; } = new();
public TestKeys Keys { get; } = new();
public CapturingLogger Log { get; } = new();
public HandlerRig()
public HandlerRig(Func<string, TokenCredential>? createIdentity = null,
Func<string, string, Func<CancellationToken, Task<string>>, TokenCredential>? createOAuth = null)
{
Env = new TestEnv
{
Expand All @@ -274,7 +404,9 @@ public HandlerRig()
};
var registry = new ProviderRegistry(new IProviderAdapter[]
{ new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() });
_function = new SendOtp(new DispatchEngine(registry, Secrets, Http, Env),
var engine = createIdentity is null ? new DispatchEngine(registry, Secrets, Http, Env)
: new DispatchEngine(registry, Secrets, Http, Env, createIdentity, createOAuth!);
_function = new SendOtp(engine,
new JweDecryptor(Keys), Env, Log);
}
public async Task<ObjectResult> Invoke(object? mode = null, string channel = "sms", string? tenantId = null,
Expand Down
1 change: 1 addition & 0 deletions javascript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions javascript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@azure/functions": "^4.0.0",
"@azure/identity": "^4.13.1",
"@azure/keyvault-secrets": "^4.11.2",
"@azure/logger": "1.3.0",
"jose": "^5.9.6"
}
}
Loading
Loading