diff --git a/TECHNICAL.md b/TECHNICAL.md
index 08fcaa9..99a01db 100644
--- a/TECHNICAL.md
+++ b/TECHNICAL.md
@@ -13,7 +13,7 @@ This reference covers implementation details, app settings, packages, and built-
| Download or build a package | [Download](#download-a-function-zip) or [build](#build-zips-locally) |
| Review request processing | [HTTP implementation details](#http-implementation-details) |
| Configure settings | [Environment variables](#configure-environment-variables) |
-| Configure Soprano or Telesign | [Built-in providers](#built-in-providers) |
+| Configure Infobip, Soprano, or Telesign | [Built-in providers](#built-in-providers) |
| Prepare for live use | [Production readiness](#production-readiness) |
| Review trust boundaries | [Security](#security) |
| Design regional continuity | [Resiliency and business continuity](#resiliency-and-business-continuity) |
@@ -306,10 +306,42 @@ Every deployment can serve both configured channels. The legacy `EPP_PROVIDER_CH
-Guided setup supports the sample's built-in **Soprano** and **Telesign** integrations.
+Guided setup supports the sample's built-in **Infobip**, **Soprano**, and **Telesign** integrations.
Use the [credential and deployment-support table](docs/ONBOARDING.md#provider-credential-names)
for onboarding.
+### Infobip
+
+Infobip uses OAuth only, with the same managed-identity client-assertion exchange and settings as
+[Soprano](#soprano). No `infobip-api-key` secret is read. The
+[guided profile](setup/providers/infobip.json) supplies the Security Store manifest's provider
+tenant and API application ID; its OAuth scope is that application ID followed by `/.default`.
+Provider-side authorization of the customer application is still required.
+
+Both channel settings are **base URLs**, without a message path or trailing slash:
+
+| Provider route | `EPP_PROVIDER_SMS_ENDPOINT` and `EPP_PROVIDER_VOICE_ENDPOINT` |
+| --- | --- |
+| Global | `https://api.infobip.com` |
+| EU | `https://api-eu.infobip.com` |
+
+The adapters preserve the API-key-tested request and response contracts: append `/sms/3/messages`
+for SMS or `/tts/3/advanced` for voice, and send the existing `messages` payload with OAuth
+`Authorization: Bearer ` instead of `App `. Voice does **not** switch to
+the manifest's `/tts/3/single` contract, and neither channel uses plain HTTP.
+Message text, destinations, correlation IDs, locale, and voice options are unchanged.
+Recognized success groups remain `ACCEPTED`, `PENDING`, and `DELIVERED` on successful HTTP responses;
+unknown statuses fail closed.
+
+Set `EPP_PROVIDER_ACCOUNT_NAME` to an Infobip-approved sender/caller ID and confirm it works for
+both intended channels. The adapter's existing `Verify` fallback is not proof of sender approval.
+
+For an older API-key deployment, deploy a package containing this OAuth update and rerun setup
+with `-Provider infobip` to configure federation and the provider tenant/scope. Review
+`EPP_PROVIDER_AUTH_MODE=oauth` and the regional base URLs. Updating settings alone does not upgrade
+an older adapter; this version has no API-key fallback. Complete
+[provider authorization](docs/ONBOARDING.md#complete-provider-authentication) before live use.
+
### Soprano
Soprano uses OAuth client-assertion exchange with:
diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md
index 19682a6..950e67d 100644
--- a/docs/GETTING-STARTED.md
+++ b/docs/GETTING-STARTED.md
@@ -56,7 +56,7 @@ You can create the dedicated application during guided setup or register it manu
**Create during setup:** in Step 3, press Enter to choose **Create a new application**, then Enter to use
**EPP Application** or enter your own display name, and choose a provider by name from the
-alphabetized list (**Soprano, Telesign**). Blank provider-name input prompts again instead of
+alphabetized list (**Infobip, Soprano, Telesign**). Blank provider-name input prompts again instead of
canceling or selecting a default.
Setup creates the registration only after you approve the final deployment plan.
Canceling before approval creates no application. The signed-in user must be allowed to register
diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md
index 96d3866..7f63f88 100644
--- a/docs/ONBOARDING.md
+++ b/docs/ONBOARDING.md
@@ -8,7 +8,7 @@ After deployment, connect the provider, validate the endpoint, and assign operat
| --- | --- |
| Confirm access before spending | [Before deployment](#before-purchasing-or-deploying) |
| Gather setup values | [Values and ownership](#values-and-ownership) |
-| Connect Soprano or Telesign | [Provider authentication](#complete-provider-authentication) |
+| Connect Infobip, Soprano, or Telesign | [Provider authentication](#complete-provider-authentication) |
| Run approved tests | [Validate the endpoint](#validate-the-deployed-endpoint) |
| Activate and assign owners | [Operations](#activate-operate-and-retire) |
| Deploy custom code | [Custom deployment](#optional-local-development-and-manual-deployment) |
@@ -48,7 +48,7 @@ renewal.
| Azure subscription ID | Azure portal > Subscriptions | `-SubscriptionId` |
| Endpoint application | Existing dedicated app's client ID, or Enter to create a new app (default name `EPP Application`) | `-ApplicationId`, or the interactive **Create a new application** default |
| Azure region | Available Linux FC1/EP1 region that meets quota and organization requirements | `-Location` |
-| Provider | Approved Security Store integration | `-Provider telesign` or `soprano` |
+| Provider | Approved Security Store integration | `-Provider infobip`, `soprano`, or `telesign` |
| Provider route | Detected from the tenant's replication scope; EU for EU tenants, Global otherwise | Optional `-EndpointRegion eu` or `global` must match the detected route |
| Language and plan | JavaScript by default; C# or Python by parameter; Enter defaults to FC1 hosting | `-Language csharp` or `python`, `-ServicePlan` |
| Resource prefix | Interactive default `epp`, or a unique 2-8 character lowercase label that starts with a letter | `-ResourcePrefix` (required unattended) |
@@ -84,9 +84,9 @@ the approved plan. `policyChanged: false` is expected because setup does not act
| `EPP_PROVIDER_NAME` | Selects the registered adapter | Matches the approved provider |
| `EPP_PROVIDER_SMS_ENDPOINT`, `EPP_PROVIDER_VOICE_ENDPOINT` | Channel-specific provider URLs | Provider confirms access to each channel |
| `EPP_PROVIDER_ENDPOINT_REGION` | Provider route label | Matches Global or EU selection |
-| `EPP_PROVIDER_AUTH_MODE` | `apiKey` for Telesign or `oauth` for Soprano | Matches the selected profile |
-| `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_APP_ID`, `EPP_PROVIDER_SCOPE` | Soprano API tenant, app, and scope | Provider administrator confirms exact values |
-| `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | Customer app and outbound identity used for Soprano federation | Do not substitute object IDs |
+| `EPP_PROVIDER_AUTH_MODE` | `apiKey` for Telesign or `oauth` for Infobip/Soprano | Matches the selected profile |
+| `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_APP_ID`, `EPP_PROVIDER_SCOPE` | OAuth provider API tenant, app, and scope | Provider administrator confirms exact values |
+| `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | Customer app and outbound identity used for OAuth federation | Do not substitute object IDs |
| `KEY_VAULT_URL` | Vault holding API-key provider credentials | Use the summary's vault URI |
| `EPP_DECRYPTION_KEY_PEM`, `EPP_ENCRYPTION_KEY_ID` | Versioned encryption key reference and registered key ID | Assign a renewal owner |
| `EPP_PROVIDER_TIMEOUT_MS`, `EPP_PROVIDER_RETRY_INTERVAL_MS` | Provider timing values | Retry interval metadata does not enable retries |
@@ -104,16 +104,22 @@ Settings select code that already exists. They do not install a provider adapter
## Provider authentication
-Guided setup supports **Soprano and Telesign**.
+Guided setup supports **Infobip, Soprano, and Telesign**.
| Provider | Authentication | Customer action |
| --- | --- | --- |
+| Infobip | OAuth client assertion through managed identity federation | Complete provider-side authorization and configure an approved sender/caller ID in `EPP_PROVIDER_ACCOUNT_NAME`; no Infobip API-key secret |
| Soprano | OAuth client assertion through managed identity federation | Complete provider-side authorization and confirm tenant, app, scope, account, and channels |
| Telesign | Basic authentication from Key Vault secrets | Add `telesign-api-key` and `telesign-customer-id` to the setup-created vault |
-Infobip and Sinch adapters are bundled but do not have guided setup profiles. Twilio requires the
+Sinch is bundled but does not have a guided setup profile. Twilio requires the
[Add Twilio](BRING-YOUR-OWN-PROVIDER.md) development process.
+Infobip retains the previously tested SMS and advanced voice API contracts, using OAuth rather
+than API-key authentication. Its channel settings are HTTPS regional base URLs, not the complete
+message paths in the Security Store manifest. See
+[Infobip routing and migration](../TECHNICAL.md#infobip).
+
### Add Telesign secrets
@@ -140,9 +146,10 @@ During rotation, coordinate both values and their provider validity window. Veri
test before retiring the old pair. Evaluation does not test provider credentials.
-### Complete Soprano authorization
+
+### Complete Infobip or Soprano authorization
-Setup creates the customer-side federation. The Soprano administrator still needs to authorize the
+Setup creates the customer-side federation. The provider administrator still needs to authorize the
customer application and account.
Send identifiers only through the approved onboarding route:
@@ -166,6 +173,10 @@ access. Then inspect **App registrations > Certificates & secrets > Federated cr
The identity client ID is not the federation subject. Customer-side configuration alone does not
prove provider-side authorization. Verify the integration with an approved controlled live test.
+For Infobip, set the Function App's `EPP_PROVIDER_ACCOUNT_NAME` to the approved sender/caller ID
+and confirm account access to SMS, voice, and the selected regional host. Do not assume the existing
+`Verify` fallback is an approved voice caller ID. No API key or client secret is required.
+
## Validate the endpoint
diff --git a/docs/README.md b/docs/README.md
index d1e1531..9078356 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -21,7 +21,7 @@ Use the guide that matches your task.
## Understand and develop
- [Technical reference](../TECHNICAL.md)
- - [Soprano and Telesign](../TECHNICAL.md#built-in-providers)
+ - [Infobip, Soprano, and Telesign](../TECHNICAL.md#built-in-providers)
- [Send OTP API](CONTRACT.md)
- Runtime guides:
- [JavaScript](../javascript/README.md)
diff --git a/dotnet/README.md b/dotnet/README.md
index d8afc90..45401c7 100644
--- a/dotnet/README.md
+++ b/dotnet/README.md
@@ -76,7 +76,7 @@ authentication and decryption without a provider send. Live success confirms acc
**not handset receipt**. Check a timeout before retrying.
For built-in message and voice mapping, see
-[Soprano and Telesign](../TECHNICAL.md#built-in-providers).
+[Infobip, Soprano, and Telesign](../TECHNICAL.md#built-in-providers).
## Source
@@ -95,8 +95,8 @@ accounts require a separate trust, configuration, credential, and test design.
`CredentialTokenService` invokes the selected provider's credential retrieval for each live
request with a 2.5-second cancellation budget. API-key secrets are read from Key Vault each time.
-The OAuth provider reuses Azure Identity SDK credential instances, leaving token caching and
-renewal to the SDK. There is no custom credential cache, hosted warmup service, polling, or
+The OAuth providers share `OAuthCredentialSource` and reuse Azure Identity SDK credential instances,
+leaving token caching and renewal to the SDK. There is no custom credential cache, hosted warmup service, polling, or
retry cooldown. Evaluation does no provider credential work. See
[credential acquisition](../TECHNICAL.md#provider-settings-and-credential-acquisition).
@@ -108,6 +108,7 @@ retry cooldown. Evaluation does no provider credential work. See
| [Src/EntraSendOtpPayload.cs](Src/EntraSendOtpPayload.cs) | Bound request model, strict channel/mode converters and semantic validation |
| [Src/JweDeliveryContext.cs](Src/JweDeliveryContext.cs) | Pinned JWE decryption and decrypted delivery context |
| [Src/CredentialTokenService.cs](Src/CredentialTokenService.cs) | Bounded provider-supplied credential retrieval for each live request |
+| [Src/OAuthCredentialSource.cs](Src/OAuthCredentialSource.cs) | Shared managed-identity assertion exchange and reusable SDK clients for Infobip/Soprano |
| [Src/OtpLog.cs](Src/OtpLog.cs) | Source-generated, strongly typed [structured logging events](../docs/LOGGING.md#event-sequence) |
| [Src/PhoneProviderBase.cs](Src/PhoneProviderBase.cs) | Provider extension contract and shared typed JSON/HTTP transport |
| [Src/Providers/](Src/Providers/) | Provider identity, credential delegation and API-specific request/response protocols |
diff --git a/dotnet/Src/OAuthCredentialSource.cs b/dotnet/Src/OAuthCredentialSource.cs
new file mode 100644
index 0000000..fccdc58
--- /dev/null
+++ b/dotnet/Src/OAuthCredentialSource.cs
@@ -0,0 +1,91 @@
+using Azure.Core;
+using Azure.Identity;
+
+namespace Epp.Otp;
+
+internal sealed class OAuthCredentialSource
+{
+ private static readonly TimeSpan ExpirySkew = TimeSpan.FromSeconds(30);
+ private readonly object _credentialGate = new();
+ private readonly Func _createIdentity;
+ private readonly Func>, TokenCredential> _createCredential;
+ private (TokenCredential Credential, string Scope)? _credentials;
+
+ public OAuthCredentialSource()
+ : this(
+ identity => new ManagedIdentityCredential(identity, OAuthOptions()),
+ (tenant, application, assertion) =>
+ new ClientAssertionCredential(tenant, application, assertion, OAuthOptions()))
+ {
+ }
+
+ internal OAuthCredentialSource(
+ Func createIdentity,
+ Func>, TokenCredential> createCredential)
+ {
+ _createIdentity = createIdentity;
+ _createCredential = createCredential;
+ }
+
+ public async Task FetchAsync(
+ AppConfig config, CancellationToken cancellationToken = default)
+ {
+ var (credential, scope) = ConfigureCredentials(config);
+ var token = CheckToken(await credential.GetTokenAsync(
+ new TokenRequestContext([scope]),
+ cancellationToken).ConfigureAwait(false));
+ return new ProviderCredentials("oauth", AccessToken: token.Token);
+ }
+
+ private (TokenCredential Credential, string Scope) ConfigureCredentials(AppConfig config)
+ {
+ lock (_credentialGate)
+ {
+ if (_credentials is { } configured) return configured;
+ if (string.IsNullOrWhiteSpace(config.ProviderTenantId)
+ || string.IsNullOrWhiteSpace(config.ProviderScope)
+ || string.IsNullOrWhiteSpace(config.OutboundClientId)
+ || string.IsNullOrWhiteSpace(config.OutboundManagedIdentityClientId))
+ throw CredentialTokenService.Unavailable();
+ var identity = _createIdentity(config.OutboundManagedIdentityClientId);
+ var credential = _createCredential(
+ config.ProviderTenantId,
+ config.OutboundClientId,
+ async cancellation => (await GetAssertionAsync(identity, cancellation).ConfigureAwait(false)).Token);
+ _credentials = (credential, config.ProviderScope);
+ return _credentials.Value;
+ }
+ }
+
+ private static async Task GetAssertionAsync(TokenCredential identity, CancellationToken cancellationToken) =>
+ CheckToken(await identity.GetTokenAsync(
+ new TokenRequestContext(["api://AzureADTokenExchange/.default"]),
+ cancellationToken).ConfigureAwait(false));
+
+ private static AccessToken CheckToken(AccessToken token)
+ {
+ if (string.IsNullOrWhiteSpace(token.Token)
+ || token.ExpiresOn <= DateTimeOffset.UtcNow + ExpirySkew)
+ throw CredentialTokenService.Unavailable();
+ return token;
+ }
+
+ private static ClientAssertionCredentialOptions OAuthOptions()
+ {
+ var options = new ClientAssertionCredentialOptions
+ {
+ AuthorityHost = AzureAuthorityHosts.AzurePublicCloud,
+ Retry =
+ {
+ MaxRetries = 0,
+ NetworkTimeout = CredentialTokenService.AcquisitionTimeout,
+ },
+ Diagnostics =
+ {
+ IsLoggingEnabled = false,
+ IsLoggingContentEnabled = false,
+ },
+ };
+ return options;
+ }
+}
diff --git a/dotnet/Src/Providers/InfobipProvider.cs b/dotnet/Src/Providers/InfobipProvider.cs
index a06d2b4..34555ee 100644
--- a/dotnet/Src/Providers/InfobipProvider.cs
+++ b/dotnet/Src/Providers/InfobipProvider.cs
@@ -1,3 +1,4 @@
+using Azure.Core;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
@@ -7,12 +8,19 @@ namespace Epp.Otp.Providers;
public sealed class InfobipProvider : PhoneProviderBase
{
- private readonly ISecretResolver? _secrets;
+ private readonly OAuthCredentialSource _credentials;
- public InfobipProvider(ISecretResolver? secrets = null) => _secrets = secrets;
+ public InfobipProvider() => _credentials = new();
+
+ internal InfobipProvider(
+ Func createIdentity,
+ Func>, TokenCredential> createCredential)
+ {
+ _credentials = new(createIdentity, createCredential);
+ }
public override string Name => "infobip";
- public override string AuthenticationMode => "apiKey";
+ public override string AuthenticationMode => "oauth";
public override Task SendOtpAsync(
string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials,
@@ -61,7 +69,7 @@ [new Destination(delivery.PhoneNumber, messageId)],
{
Content = JsonContent.Create(body),
};
- request.Headers.TryAddWithoutValidation("Authorization", $"App {credential.Secret}");
+ request.Headers.Authorization = new("Bearer", credential.AccessToken);
request.Headers.Accept.ParseAdd("application/json");
return request;
}
@@ -85,17 +93,9 @@ private static ProviderResult MapResponse(Response? payload, HttpStatusCode http
};
}
- public override async Task FetchCredentialsAsync(
- AppConfig config, CancellationToken cancellationToken = default)
- {
- if (_secrets is null) throw CredentialTokenService.Unavailable();
- var secret = await _secrets.ResolveAsync(
- "infobip-api-key", cancellationToken).ConfigureAwait(false);
- if (string.IsNullOrWhiteSpace(secret)) throw CredentialTokenService.Unavailable();
- return new ProviderCredentials(
- AuthenticationMode,
- Secret: secret);
- }
+ public override Task FetchCredentialsAsync(
+ AppConfig config, CancellationToken cancellationToken = default) =>
+ _credentials.FetchAsync(config, cancellationToken);
private static (Outcome Outcome, bool Recognized) MapStatus(string? status) => status switch
{
diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs
index db794b8..23914a9 100644
--- a/dotnet/Src/Providers/SopranoProvider.cs
+++ b/dotnet/Src/Providers/SopranoProvider.cs
@@ -1,5 +1,4 @@
using Azure.Core;
-using Azure.Identity;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
@@ -15,26 +14,15 @@ public sealed class SopranoProvider : PhoneProviderBase
private const string DefaultVoiceLanguage = "en-US";
private const int VoiceGender = 1;
private const int VoiceLoop = 2;
- private static readonly TimeSpan ExpirySkew = TimeSpan.FromSeconds(30);
- private readonly object _credentialGate = new();
- private readonly Func _createIdentity;
- private readonly Func>, TokenCredential> _createCredential;
- private (TokenCredential Credential, string Scope)? _credentials;
+ private readonly OAuthCredentialSource _credentials;
- public SopranoProvider()
- : this(
- identity => new ManagedIdentityCredential(identity, OAuthOptions()),
- (tenant, application, assertion) =>
- new ClientAssertionCredential(tenant, application, assertion, OAuthOptions()))
- {
- }
+ public SopranoProvider() => _credentials = new();
internal SopranoProvider(
Func createIdentity,
Func>, TokenCredential> createCredential)
{
- _createIdentity = createIdentity;
- _createCredential = createCredential;
+ _credentials = new(createIdentity, createCredential);
}
public override string Name => "soprano";
@@ -110,69 +98,9 @@ private static ProviderResult MapResponse(ResponseBody? responseBody, HttpStatus
};
}
- public override async Task FetchCredentialsAsync(
- AppConfig config, CancellationToken cancellationToken = default)
- {
- var (credential, scope) = ConfigureCredentials(config);
- var token = CheckToken(await credential.GetTokenAsync(
- new TokenRequestContext([scope]),
- cancellationToken).ConfigureAwait(false));
- return new ProviderCredentials(
- AuthenticationMode,
- AccessToken: token.Token);
- }
-
- private (TokenCredential Credential, string Scope) ConfigureCredentials(AppConfig config)
- {
- lock (_credentialGate)
- {
- if (_credentials is { } configured) return configured;
- if (string.IsNullOrWhiteSpace(config.ProviderTenantId)
- || string.IsNullOrWhiteSpace(config.ProviderScope)
- || string.IsNullOrWhiteSpace(config.OutboundClientId)
- || string.IsNullOrWhiteSpace(config.OutboundManagedIdentityClientId))
- throw CredentialTokenService.Unavailable();
- var identity = _createIdentity(config.OutboundManagedIdentityClientId);
- var credential = _createCredential(
- config.ProviderTenantId,
- config.OutboundClientId,
- async cancellation => (await GetAssertionAsync(identity, cancellation).ConfigureAwait(false)).Token);
- _credentials = (credential, config.ProviderScope);
- return _credentials.Value;
- }
- }
-
- private static async Task GetAssertionAsync(TokenCredential identity, CancellationToken cancellationToken) =>
- CheckToken(await identity.GetTokenAsync(
- new TokenRequestContext(["api://AzureADTokenExchange/.default"]),
- cancellationToken).ConfigureAwait(false));
-
- private static AccessToken CheckToken(AccessToken token)
- {
- if (string.IsNullOrWhiteSpace(token.Token)
- || token.ExpiresOn <= DateTimeOffset.UtcNow + ExpirySkew)
- throw CredentialTokenService.Unavailable();
- return token;
- }
-
- private static ClientAssertionCredentialOptions OAuthOptions()
- {
- var options = new ClientAssertionCredentialOptions
- {
- AuthorityHost = AzureAuthorityHosts.AzurePublicCloud,
- Retry =
- {
- MaxRetries = 0,
- NetworkTimeout = CredentialTokenService.AcquisitionTimeout,
- },
- Diagnostics =
- {
- IsLoggingEnabled = false,
- IsLoggingContentEnabled = false,
- },
- };
- return options;
- }
+ public override Task FetchCredentialsAsync(
+ AppConfig config, CancellationToken cancellationToken = default) =>
+ _credentials.FetchAsync(config, cancellationToken);
private static (Outcome Outcome, bool Recognized) MapStatus(string? status) => status switch
{
diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs
index 1bb58eb..6f93bff 100644
--- a/dotnet/tests/ContractTests.cs
+++ b/dotnet/tests/ContractTests.cs
@@ -160,15 +160,18 @@ public void ProviderResultKeepsExistingConstructorAndDeconstructionShape()
Assert.Equal("provider_rejected", result.FailureReason);
}
- [Fact]
- public async Task InfobipRequestsKeepExactSmsAndVoiceWireContracts()
+ [Theory]
+ [InlineData("https://api.infobip.com")]
+ [InlineData("https://api-eu.infobip.com")]
+ public async Task InfobipOAuthRequestsKeepExactSmsAndVoiceWireContracts(string endpoint)
{
var env = new TestEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "Verify" };
- var credential = new ProviderCredentials("apiKey", "test-key", "test-id");
+ var credential = new ProviderCredentials("oauth", AccessToken: "test-token");
+ Assert.Equal("oauth", new InfobipProvider().AuthenticationMode);
var sms = await new InfobipProvider().CaptureRequestAsync(
- "sms", "https://provider.example", Delivery(), credential, env);
- AssertJsonRequest(sms, "https://provider.example/sms/3/messages", "App test-key");
+ "sms", endpoint, Delivery(), credential, env);
+ AssertJsonRequest(sms, $"{endpoint}/sms/3/messages", "Bearer test-token");
var expectedSms = new
{
messages = new[]
@@ -184,8 +187,8 @@ public async Task InfobipRequestsKeepExactSmsAndVoiceWireContracts()
Assert.Equal(JsonSerializer.Serialize(expectedSms), sms.Body);
var voice = await new InfobipProvider().CaptureRequestAsync(
- "voice", "https://provider.example", Delivery("voice"), credential, env);
- AssertJsonRequest(voice, "https://provider.example/tts/3/advanced", "App test-key");
+ "voice", endpoint, Delivery("voice"), credential, env);
+ AssertJsonRequest(voice, $"{endpoint}/tts/3/advanced", "Bearer test-token");
var expectedVoice = new
{
messages = new[]
diff --git a/dotnet/tests/CredentialAcquisitionTests.cs b/dotnet/tests/CredentialAcquisitionTests.cs
index 8aa9fc4..058e749 100644
--- a/dotnet/tests/CredentialAcquisitionTests.cs
+++ b/dotnet/tests/CredentialAcquisitionTests.cs
@@ -96,14 +96,16 @@ public async Task AcquisitionDeadlineRejectsLateResultsWithoutBlockingTheNextReq
Assert.Equal(2, calls);
}
- [Fact]
- public async Task OAuthCallsReusableSdkCredentialsForEveryRequestWithoutPrefetchingAssertions()
+ [Theory]
+ [InlineData("soprano")]
+ [InlineData("infobip")]
+ public async Task OAuthCallsReusableSdkCredentialsForEveryRequestWithoutPrefetchingAssertions(string providerName)
{
var identityCalls = 0;
var providerCalls = 0;
var identityInstances = 0;
var credentialInstances = 0;
- var provider = new SopranoProvider(
+ var provider = CreateOAuthProvider(providerName,
_ =>
{
identityInstances++;
@@ -132,13 +134,15 @@ public async Task OAuthCallsReusableSdkCredentialsForEveryRequestWithoutPrefetch
Assert.Equal(1, credentialInstances);
}
- [Fact]
- public async Task RealAzureIdentitySdkRetainsItsTokenCache()
+ [Theory]
+ [InlineData("soprano")]
+ [InlineData("infobip")]
+ public async Task RealAzureIdentitySdkRetainsItsTokenCache(string providerName)
{
var identityCalls = 0;
using var transport = new TokenTransport();
using var client = new HttpClient(transport);
- var provider = new SopranoProvider(
+ var provider = CreateOAuthProvider(providerName,
_ => new Token((_, _) =>
{
identityCalls++;
@@ -161,11 +165,13 @@ public async Task RealAzureIdentitySdkRetainsItsTokenCache()
Assert.Equal(1, identityCalls);
}
- [Fact]
- public async Task InvalidConfigurationDoesNotPoisonLaterAcquisition()
+ [Theory]
+ [InlineData("soprano")]
+ [InlineData("infobip")]
+ public async Task InvalidConfigurationDoesNotPoisonLaterAcquisition(string providerName)
{
var instances = 0;
- var provider = new SopranoProvider(
+ var provider = CreateOAuthProvider(providerName,
_ => new Token((_, _) => throw new InvalidOperationException("Unneeded assertion")),
(_, _, _) =>
{
@@ -181,6 +187,15 @@ await Assert.ThrowsAsync(() =>
Assert.Equal(1, instances);
}
+ private static PhoneProviderBase CreateOAuthProvider(
+ string name, Func createIdentity,
+ Func>, TokenCredential> createCredential) => name switch
+ {
+ "soprano" => new SopranoProvider(createIdentity, createCredential),
+ "infobip" => new InfobipProvider(createIdentity, createCredential),
+ _ => throw new ArgumentException("Unexpected OAuth provider", nameof(name)),
+ };
+
private static AppConfig OAuthConfig(string scope = "api://provider/.default") => new()
{
ProviderTenantId = "11111111-1111-1111-1111-111111111111",
diff --git a/dotnet/tests/SendOtpTests.cs b/dotnet/tests/SendOtpTests.cs
index 1cc723f..2da9e92 100644
--- a/dotnet/tests/SendOtpTests.cs
+++ b/dotnet/tests/SendOtpTests.cs
@@ -50,6 +50,7 @@ public async Task OptionalOtpReachesProviderWithoutEvaluationIo(
AssertAccepted(await rig.Invoke("evaluation", channel: channel, deliveryOverrides: changes));
Assert.Null(rig.Infobip.Delivery);
+ Assert.Equal(0, rig.OAuthCalls);
Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls));
AssertAccepted(await rig.Invoke(channel: channel, deliveryOverrides: changes));
@@ -58,7 +59,7 @@ public async Task OptionalOtpReachesProviderWithoutEvaluationIo(
Assert.Equal(message, delivery.Message);
Assert.Equal(channel, delivery.Channel);
Assert.Equal(nameof(OtpDelivery), delivery.ToString());
- Assert.Equal((1, 1), (rig.Secrets.Calls, rig.Http.Calls));
+ Assert.Equal((1, 0, 1), (rig.OAuthCalls, rig.Secrets.Calls, rig.Http.Calls));
Assert.DoesNotContain("001234", string.Join("\n", rig.Log.Messages));
Assert.DoesNotContain("001234", JsonSerializer.Serialize(rig.Log.Records));
}
@@ -75,6 +76,7 @@ public async Task RiskContextDictionaryReachesAdapterWithoutChangingProviderPayl
var changes = fixture.GetProperty("overrides");
AssertAccepted(await rig.Invoke("evaluation", channel: channel, deliveryOverrides: changes));
Assert.Null(rig.Infobip.Delivery);
+ Assert.Equal(0, rig.OAuthCalls);
Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls));
AssertAccepted(await rig.Invoke(channel: channel, deliveryOverrides: changes));
@@ -91,7 +93,7 @@ public async Task RiskContextDictionaryReachesAdapterWithoutChangingProviderPayl
Assert.Equal(Message, delivery.Message);
Assert.Equal(channel, delivery.Channel);
Assert.Equal(nameof(OtpDelivery), delivery.ToString());
- Assert.Equal((1, 1), (rig.Secrets.Calls, rig.Http.Calls));
+ Assert.Equal((1, 0, 1), (rig.OAuthCalls, rig.Secrets.Calls, rig.Http.Calls));
var sentBody = rig.Http.Body;
AssertAccepted(await rig.Invoke(channel: channel));
Assert.Equal(sentBody, rig.Http.Body);
@@ -122,9 +124,9 @@ public async Task NonObjectRiskContextFailsBeforeProviderWork(string mode)
Assert.DoesNotContain("PRIVATE-RISK", JsonSerializer.Serialize(rig.Log.Records));
}
- private static void ConfigureSoprano(HandlerRig rig)
+ private static void ConfigureOAuth(HandlerRig rig, string provider = "soprano")
{
- rig.Env["EPP_PROVIDER_NAME"] = "soprano";
+ rig.Env["EPP_PROVIDER_NAME"] = provider;
rig.Env["EPP_PROVIDER_AUTH_MODE"] = "oauth";
rig.Env["EPP_PROVIDER_SMS_ENDPOINT"] = "https://provider.example/full/send/";
rig.Env["EPP_PROVIDER_VOICE_ENDPOINT"] = "https://provider.example/full/send/";
@@ -133,7 +135,9 @@ private static void ConfigureSoprano(HandlerRig rig)
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\"}"));
+ rig.Http.Respond = _ => Task.FromResult(Json(201, provider == "infobip"
+ ? "{\"messages\":[{\"status\":{\"groupName\":\"PENDING\"}}]}"
+ : "{\"status\":\"ENROUTE\"}"));
}
[Theory]
@@ -154,7 +158,7 @@ public async Task RequestSelectsEitherChannelRegardlessOfLegacySetting(string pr
ValueTask.FromResult(new AccessToken("token", DateTimeOffset.UtcNow.AddHours(1)))));
if (provider == "soprano")
{
- ConfigureSoprano(rig);
+ ConfigureOAuth(rig);
}
else
{
@@ -188,9 +192,9 @@ public async Task RequestChannelSelectsDistinctConfiguredEndpoints(string provid
ValueTask.FromResult(new AccessToken("assertion", DateTimeOffset.UtcNow.AddHours(1)))),
(_, _, _) => new TestTokenCredential((_, _) =>
ValueTask.FromResult(new AccessToken("token", DateTimeOffset.UtcNow.AddHours(1)))));
- if (provider == "soprano") ConfigureSoprano(rig);
+ if (provider is "soprano" or "infobip") ConfigureOAuth(rig, provider);
rig.Env["EPP_PROVIDER_NAME"] = provider;
- rig.Env["EPP_PROVIDER_AUTH_MODE"] = provider == "soprano" ? "oauth" : "apiKey";
+ rig.Env["EPP_PROVIDER_AUTH_MODE"] = provider is "soprano" or "infobip" ? "oauth" : "apiKey";
rig.Env["EPP_PROVIDER_SMS_ENDPOINT"] = "https://sms.provider.example/send";
rig.Env["EPP_PROVIDER_VOICE_ENDPOINT"] = "https://voice.provider.example/send";
rig.Env["SINCH_SERVICE_PLAN_ID"] = "plan";
@@ -215,6 +219,11 @@ public async Task RequestChannelSelectsDistinctConfiguredEndpoints(string provid
{
AssertAccepted(await rig.Invoke(channel: channel));
Assert.Equal(endpoint + path, rig.Http.Url);
+ if (provider == "infobip")
+ {
+ Assert.Equal("Bearer token", rig.Http.Headers["Authorization"]);
+ Assert.Equal(0, rig.Secrets.Calls);
+ }
}
}
Assert.Equal(4, rig.Http.Calls);
@@ -252,6 +261,8 @@ public async Task ChannelRequiresItsEndpointWithoutLegacyOrCrossChannelFallback(
public async Task OnlyLiveRequestsFetchSecretsAndObsoleteCacheSettingsAreIgnored()
{
using var rig = new HandlerRig();
+ rig.Env["EPP_PROVIDER_NAME"] = "telesign";
+ rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"status\":{\"code\":3001}}"));
rig.Env["EPP_KEY_VAULT_CACHE_ENABLED"] = "PRIVATE-OBSOLETE";
rig.Env["EPP_ACCESS_TOKEN_CACHE_ENABLED"] = "PRIVATE-OBSOLETE";
Assert.Equal(0, rig.Secrets.Calls);
@@ -261,14 +272,16 @@ public async Task OnlyLiveRequestsFetchSecretsAndObsoleteCacheSettingsAreIgnored
Assert.Equal(0, rig.Http.Calls);
AssertAccepted(await rig.Invoke());
AssertAccepted(await rig.Invoke());
- Assert.Equal(2, rig.Secrets.Calls);
+ Assert.Equal(4, rig.Secrets.Calls);
Assert.Equal(2, rig.Http.Calls);
}
[Theory]
- [InlineData("false")]
- [InlineData("PRIVATE-OBSOLETE")]
- public async Task OAuthAcquisitionIgnoresObsoleteCacheSettingsAndSkipsEvaluation(string setting)
+ [InlineData("soprano", "false")]
+ [InlineData("soprano", "PRIVATE-OBSOLETE")]
+ [InlineData("infobip", "false")]
+ [InlineData("infobip", "PRIVATE-OBSOLETE")]
+ public async Task OAuthAcquisitionIgnoresObsoleteCacheSettingsAndSkipsEvaluation(string provider, string setting)
{
var calls = 0;
using var rig = new HandlerRig(
@@ -279,7 +292,7 @@ public async Task OAuthAcquisitionIgnoresObsoleteCacheSettingsAndSkipsEvaluation
calls++;
return ValueTask.FromResult(new AccessToken("token", DateTimeOffset.UtcNow.AddHours(1)));
}));
- ConfigureSoprano(rig);
+ ConfigureOAuth(rig, provider);
rig.Env["EPP_ACCESS_TOKEN_CACHE_ENABLED"] = setting;
rig.Env["EPP_KEY_VAULT_CACHE_ENABLED"] = "PRIVATE-UNUSED";
AssertAccepted(await rig.Invoke("evaluation"));
@@ -303,9 +316,10 @@ public async Task StartupWithoutProviderConfigurationKeepsEvaluationIndependent(
}
[Theory]
- [InlineData("api://provider/.default")]
- [InlineData("api://second/.default")]
- public async Task SopranoOAuthUsesSetupIdentitiesAndScopeForEveryRequest(string scope)
+ [InlineData("soprano", "api://provider/.default")]
+ [InlineData("soprano", "api://second/.default")]
+ [InlineData("infobip", "d1131a30-5ed5-49b7-8602-d978c8eb1677/.default")]
+ public async Task OAuthUsesSetupIdentitiesAndScopeForEveryRequest(string provider, string scope)
{
var scopes = new List();
var identities = new List();
@@ -330,7 +344,7 @@ public async Task SopranoOAuthUsesSetupIdentitiesAndScopeForEveryRequest(string
return new AccessToken("private-provider-token", DateTimeOffset.UtcNow.AddHours(1));
});
});
- ConfigureSoprano(rig);
+ ConfigureOAuth(rig, provider);
rig.Env["EPP_PROVIDER_SCOPE"] = scope;
AssertAccepted(await rig.Invoke("evaluation"));
Assert.Empty(applications);
@@ -345,7 +359,7 @@ public async Task SopranoOAuthUsesSetupIdentitiesAndScopeForEveryRequest(string
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")
+ if (provider == "soprano" && channel == "voice")
{
using var body = JsonDocument.Parse(rig.Http.Body!);
var speech = body.RootElement.GetProperty("voice").GetProperty("text2voice");
@@ -370,11 +384,15 @@ public async Task SopranoOAuthUsesSetupIdentitiesAndScopeForEveryRequest(string
}
[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)
+ [InlineData("soprano", true, "", 3600)]
+ [InlineData("soprano", true, "private-assertion", 5)]
+ [InlineData("soprano", false, " ", 3600)]
+ [InlineData("soprano", false, "private-token", -1)]
+ [InlineData("infobip", true, "", 3600)]
+ [InlineData("infobip", true, "private-assertion", 5)]
+ [InlineData("infobip", false, " ", 3600)]
+ [InlineData("infobip", false, "private-token", -1)]
+ public async Task OAuthRejectsUnusableTokensBeforeProviderIo(string provider, 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
@@ -384,13 +402,15 @@ public async Task SopranoOAuthRejectsUnusableTokensBeforeProviderIo(bool invalid
await assertion(cancellation);
return invalid;
}));
- ConfigureSoprano(rig);
+ ConfigureOAuth(rig, provider);
AssertFailure(rig, await rig.Invoke(), 502);
Assert.Equal((0, 0), (rig.Http.Calls, rig.Secrets.Calls));
}
- [Fact]
- public async Task SopranoOAuthRecoversAfterTimeoutWithoutRestartAndNeverRetriesProviderRejection()
+ [Theory]
+ [InlineData("soprano")]
+ [InlineData("infobip")]
+ public async Task OAuthRecoversAfterTimeoutWithoutRestartAndNeverRetriesProviderRejection(string provider)
{
CancellationToken observed = default;
var waitForCancellation = true;
@@ -410,7 +430,7 @@ TokenCredential CreateProvider(string tenant, string application, Func record.GetProperty("eventName").GetString()));
var selected = rig.Log.Entry("provider_selected");
Assert.Equal("infobip", selected.State["ProviderName"]);
- Assert.Equal("apiKey", selected.State["AuthenticationMode"]);
+ Assert.Equal("oauth", selected.State["AuthenticationMode"]);
var processed = rig.Log.Entry("provider_response_processed");
Assert.Equal(200, processed.State["ProviderHttpStatus"]);
Assert.Equal("PENDING", processed.State["ProviderStatus"]);
@@ -470,7 +490,7 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate()
Assert.Equal(2500, rig.Log.Entry("provider_request_started").State["TimeoutMs"]);
Assert.Equal(Correlation, processed.Scope["MsCorrelationId"]);
var log = string.Join("\n", rig.Log.Messages);
- foreach (var value in new[] { Phone, "918273", "001234", Nonce, "private-api-key", "private-api-id" })
+ foreach (var value in new[] { Phone, "918273", "001234", Nonce, "private-api-key", "private-api-id", "private-provider-token" })
Assert.DoesNotContain(value, log);
}
@@ -758,9 +778,9 @@ public async Task FailuresEmitSeparateServiceEventsAndCompleteSummaries(string s
case "decryption": rig.Env["EPP_DECRYPTION_KEY_PEM"] = PrivateError; break;
case "incomplete_context": delivery = JsonSerializer.SerializeToElement(new { nonce = "" }); break;
case "unknown_provider": rig.Env["EPP_PROVIDER_NAME"] = "PRIVATE-UNKNOWN-PROVIDER"; break;
- case "authentication_mismatch": rig.Env["EPP_PROVIDER_AUTH_MODE"] = "oauth"; break;
+ case "authentication_mismatch": rig.Env["EPP_PROVIDER_AUTH_MODE"] = "apiKey"; break;
case "invalid_endpoint": rig.Env["EPP_PROVIDER_SMS_ENDPOINT"] = "http://PRIVATE-ENDPOINT"; break;
- case "credentials": rig.Secrets.Error = new InvalidOperationException(PrivateError); break;
+ case "credentials": rig.OAuthError = new InvalidOperationException(PrivateError); break;
case "request_build":
rig.Env["EPP_PROVIDER_NAME"] = "telesign";
delivery = JsonSerializer.SerializeToElement(new { phoneNumber = "PRIVATE-INVALID-PHONE" });
@@ -807,7 +827,7 @@ public async Task FailuresEmitSeparateServiceEventsAndCompleteSummaries(string s
Assert.Equal(stage != "request_validation", summary.GetProperty("responseContainsCorrelationId").GetBoolean());
if (scenario == "credentials")
{
- Assert.Equal("key_vault", summary.GetProperty("providerCredentialSource").GetString());
+ Assert.Equal("managed_identity_client_assertion", summary.GetProperty("providerCredentialSource").GetString());
Assert.Equal(JsonValueKind.Null, summary.GetProperty("providerCredentialElapsedMs").ValueKind);
Assert.DoesNotContain(rig.Log.Records, record => record.GetProperty("eventName").GetString() == "provider_credential_resolved");
}
@@ -825,7 +845,7 @@ public async Task SuccessfulLifecycleLogsAllowedBodyMetadataRawOAuthIdsAndEndpoi
Assert.Equal("PRIVATE-ASSERTION", await assertion(cancellation));
return new AccessToken("PRIVATE-TOKEN", DateTimeOffset.UtcNow.AddHours(1));
}));
- ConfigureSoprano(rig);
+ ConfigureOAuth(rig);
rig.Env["EPP_PROVIDER_SMS_ENDPOINT"] = "https://provider.example/api/send?key=PRIVATE-QUERY";
rig.Env["EPP_PROVIDER_TENANT_ID"] = "provider-tenant-id";
rig.Env["EPP_OUTBOUND_CLIENT_ID"] = "outbound-client-id";
@@ -1090,6 +1110,9 @@ private static HttpResponseMessage Json(int status, string body) =>
private sealed class HandlerRig : IDisposable
{
private readonly SendOtp _function;
+ private int _oauthCalls;
+ public int OAuthCalls => _oauthCalls;
+ public Exception? OAuthError { get; set; }
public TestEnv Env { get; }
public TestSecrets Secrets { get; } = new();
public TestHttp Http { get; } = new();
@@ -1107,16 +1130,27 @@ public HandlerRig(Func? createIdentity = null,
["EPP_PROVIDER_SMS_ENDPOINT"] = "https://provider.example",
["EPP_PROVIDER_VOICE_ENDPOINT"] = "https://provider.example",
["EPP_PROVIDER_TIMEOUT_MS"] = "2500",
+ ["EPP_PROVIDER_TENANT_ID"] = "provider-tenant",
+ ["EPP_PROVIDER_SCOPE"] = "api://provider/.default",
+ ["EPP_OUTBOUND_CLIENT_ID"] = "calling-application",
+ ["EPP_OUTBOUND_MI_CLIENT_ID"] = "outbound-identity",
["EPP_DECRYPTION_KEY_PEM"] = Keys.Rsa.ExportPkcs8PrivateKeyPem(),
};
- Infobip = new CapturingProvider(new InfobipProvider(Secrets));
+ createIdentity ??= _ => new TestTokenCredential((_, _) =>
+ ValueTask.FromResult(new AccessToken("private-assertion", DateTimeOffset.UtcNow.AddHours(1))));
+ createOAuth ??= (_, _, assertion) => new TestTokenCredential(async (_, cancellation) =>
+ {
+ Interlocked.Increment(ref _oauthCalls);
+ if (OAuthError is not null) throw OAuthError;
+ await assertion(cancellation);
+ return new AccessToken("private-provider-token", DateTimeOffset.UtcNow.AddHours(1));
+ });
+ Infobip = new CapturingProvider(new InfobipProvider(createIdentity, createOAuth));
PhoneProviderBase[] providers =
{
Infobip,
new TelesignProvider(Secrets),
- new SopranoProvider(
- createIdentity ?? (_ => throw new InvalidOperationException("Unexpected managed identity")),
- createOAuth ?? ((_, _, _) => throw new InvalidOperationException("Unexpected OAuth"))),
+ new SopranoProvider(createIdentity, createOAuth),
new SinchProvider(Secrets),
};
Credentials = new CredentialTokenService();
diff --git a/javascript/README.md b/javascript/README.md
index ab1a63b..69f4065 100644
--- a/javascript/README.md
+++ b/javascript/README.md
@@ -69,7 +69,7 @@ authentication and decryption without a provider send. Live success confirms acc
**not handset receipt**. Check a timeout before retrying.
For built-in message and voice mapping, see
-[Soprano and Telesign](../TECHNICAL.md#built-in-providers).
+[Infobip, Soprano, and Telesign](../TECHNICAL.md#built-in-providers).
## Source and extension points
diff --git a/javascript/src/functions/providers/infobip.js b/javascript/src/functions/providers/infobip.js
index b92b98e..fafca5a 100644
--- a/javascript/src/functions/providers/infobip.js
+++ b/javascript/src/functions/providers/infobip.js
@@ -7,14 +7,14 @@ const REJECTED = new Set(['REJECTED', 'EXPIRED', 'UNDELIVERABLE']);
const provider = Object.freeze({
name: 'infobip',
- authenticationMode: 'apiKey',
- credentialSpec: Object.freeze({ mode: 'apiKey', keyVaultSecretName: 'infobip-api-key' }),
+ authenticationMode: 'oauth',
+ credentialSpec: Object.freeze({ mode: 'oauth' }),
createRequest({ channel, endpoint, delivery, credential, env }) {
const senderId = env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify';
const messageId = delivery.correlationId || delivery.messageId;
const headers = {
- Authorization: `App ${credential.secret}`,
+ Authorization: ['Bearer', credential.accessToken].join(' '),
'Content-Type': 'application/json',
Accept: 'application/json',
};
diff --git a/javascript/test/provider-flow.test.js b/javascript/test/provider-flow.test.js
index d7c6f40..d36587e 100644
--- a/javascript/test/provider-flow.test.js
+++ b/javascript/test/provider-flow.test.js
@@ -224,11 +224,50 @@ test('Soprano accepts root or first array object with strict status and flexible
}
});
+test('Infobip uses OAuth with the tested SMS and advanced voice contracts on both setup routes', () => {
+ const infobip = selectProvider('infobip');
+ const profile = require('../../setup/providers/infobip.json');
+ assert.equal(infobip.authenticationMode, 'oauth');
+ assert.deepEqual(infobip.credentialSpec, { mode: 'oauth' });
+ for (const [region, baseUrl] of [
+ ['global', 'https://api.infobip.com'],
+ ['eu', 'https://api-eu.infobip.com'],
+ ]) {
+ for (const channel of ['sms', 'voice']) {
+ const request = infobip.createRequest({
+ ...input,
+ channel,
+ endpoint: profile.deployment.routes[region][`${channel}Endpoint`],
+ credential: { mode: 'oauth', accessToken: 'provider-token' },
+ env: { EPP_PROVIDER_ACCOUNT_NAME: 'EPP' },
+ });
+ assert.equal(request.url, baseUrl + (channel === 'voice' ? '/tts/3/advanced' : '/sms/3/messages'));
+ assert.equal(request.method, 'POST');
+ assert.deepEqual(request.headers, {
+ Authorization: ['Bearer', 'provider-token'].join(' '),
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ });
+ const destinations = [{ to: delivery.phoneNumber, messageId: 'correlation-id' }];
+ assert.deepEqual(JSON.parse(request.body), {
+ messages: [channel === 'voice' ? {
+ from: 'EPP',
+ destinations,
+ text: delivery.message,
+ language: 'fr-FR',
+ voice: { name: 'Joanna', gender: 'female' },
+ } : {
+ sender: 'EPP',
+ destinations,
+ content: { text: delivery.message },
+ }],
+ });
+ }
+ }
+});
+
test('Infobip accepts only strict recognized status strings', () => {
const infobip = selectProvider('infobip');
- const request = infobip.createRequest(input);
- assert.equal(request.headers.Authorization, 'App key');
- assert.equal(JSON.parse(request.body).messages[0].content.text, delivery.message);
for (const [status, expected] of [
['ACCEPTED', OUTCOME.CONTINUE],
['pending', OUTCOME.CONTINUE],
diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js
index a0b0a36..fa6ca4b 100644
--- a/javascript/test/sendotp.test.js
+++ b/javascript/test/sendotp.test.js
@@ -277,7 +277,7 @@ for (const mode of [1, 2]) {
});
}
-for (const provider of ['telesign', 'soprano']) {
+for (const provider of ['telesign', 'soprano', 'infobip']) {
test(`${provider}: only live requests acquire credentials and obsolete cache settings are ignored`, async () => {
const apiKey = provider === 'telesign';
process.env.EPP_PROVIDER_NAME = provider;
@@ -287,6 +287,10 @@ for (const provider of ['telesign', 'soprano']) {
if (apiKey) fetchMock.mock.mockImplementation(async () => ({
ok: true, status: 200, text: async () => JSON.stringify({ status: { code: 3001 } }),
}));
+ if (provider === 'infobip') fetchMock.mock.mockImplementation(async () => ({
+ ok: true, status: 200,
+ text: async () => JSON.stringify({ messages: [{ status: { groupName: 'PENDING' } }] }),
+ }));
assert.equal(startHook, undefined);
const evaluated = await invoke(await envelope({ mode: 2 }));
assert.equal(evaluated.status, 200);
@@ -541,7 +545,7 @@ for (const [provider, response, smsPath, voicePath] of [
test(`${provider}: request channel selects distinct configured endpoints`, async () => {
Object.assign(process.env, {
EPP_PROVIDER_NAME: provider,
- EPP_PROVIDER_AUTH_MODE: provider === 'soprano' ? 'oauth' : 'apiKey',
+ EPP_PROVIDER_AUTH_MODE: ['soprano', 'infobip'].includes(provider) ? 'oauth' : 'apiKey',
EPP_PROVIDER_SMS_ENDPOINT: 'https://sms.provider.example/send',
EPP_PROVIDER_VOICE_ENDPOINT: 'https://voice.provider.example/send',
SINCH_SERVICE_PLAN_ID: 'plan',
@@ -561,6 +565,11 @@ for (const [provider, response, smsPath, voicePath] of [
assert.equal(result.status, 200);
assert.equal(result.jsonBody.nonce, baseDelivery.nonce);
assert.equal(fetchMock.mock.calls.at(-1).arguments[0], endpoint + path);
+ if (provider === 'infobip') {
+ assert.equal(fetchMock.mock.calls.at(-1).arguments[1].headers.Authorization,
+ ['Bearer', 'PRIVATE-OAUTH-TOKEN'].join(' '));
+ assert.equal(getSecret.mock.callCount(), 0);
+ }
}
}
assert.equal(fetchMock.mock.callCount(), 4);
diff --git a/python/README.md b/python/README.md
index d22e7ca..6a42d3f 100644
--- a/python/README.md
+++ b/python/README.md
@@ -69,7 +69,7 @@ authentication and decryption without a provider send. Live success confirms acc
**not handset receipt**. Check a timeout before retrying.
For built-in message and voice mapping, see
-[Soprano and Telesign](../TECHNICAL.md#built-in-providers).
+[Infobip, Soprano, and Telesign](../TECHNICAL.md#built-in-providers).
## Source
diff --git a/python/src/providers/infobip.py b/python/src/providers/infobip.py
index b285bd7..91b809b 100644
--- a/python/src/providers/infobip.py
+++ b/python/src/providers/infobip.py
@@ -6,15 +6,15 @@
class InfobipProvider(PhoneProviderBase):
name = "infobip"
- authentication_mode = "apiKey"
+ authentication_mode = "oauth"
@property
def credential_spec(self):
- return {"mode": self.authentication_mode, "key_vault_secret_name": "infobip-api-key"}
+ return {"mode": self.authentication_mode}
def build_request(self, channel, endpoint, delivery, credential, env):
sender_id = env.get("EPP_PROVIDER_ACCOUNT_NAME") or "Verify"
- authorization = f"App {credential['secret']}"
+ authorization = " ".join(("Bearer", credential["access_token"]))
headers = {"Authorization": authorization, "Content-Type": "application/json", "Accept": "application/json"}
message_id = delivery.correlation_id or delivery.message_id
diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py
index edbb09c..e420112 100644
--- a/python/tests/test_contract.py
+++ b/python/tests/test_contract.py
@@ -87,17 +87,42 @@ def test_soprano_preserves_protocol_specific_mixed_response_shapes(body, expecte
assert result.failure_reason == (None if recognized else "unrecognized_provider_status")
-def test_infobip_contract_is_strict_and_provider_owned():
- request = InfobipProvider().build_request(
- "sms", "https://infobip.example", _delivery(),
- {"mode": "apiKey", "secret": "ib"}, {"EPP_PROVIDER_ACCOUNT_NAME": "EPP"})
- assert request.url == "https://infobip.example/sms/3/messages"
- assert request.headers["Authorization"] == "App ib"
- assert json.loads(request.body)["messages"] == [{
+@pytest.mark.parametrize("region,base_url", [
+ ("global", "https://api.infobip.com"),
+ ("eu", "https://api-eu.infobip.com"),
+])
+@pytest.mark.parametrize("channel", ["sms", "voice"])
+def test_infobip_oauth_preserves_tested_contract_on_both_setup_routes(region, base_url, channel):
+ profile = json.loads((Path(__file__).parents[2] / "setup" / "providers" / "infobip.json").read_text())
+ provider = InfobipProvider()
+ assert provider.authentication_mode == "oauth"
+ assert provider.credential_spec == {"mode": "oauth"}
+ request = provider.build_request(
+ channel, profile["deployment"]["routes"][region][f"{channel}Endpoint"], _delivery(channel),
+ {"mode": "oauth", "access_token": "provider-token"}, {"EPP_PROVIDER_ACCOUNT_NAME": "EPP"})
+ assert request.url == base_url + ("/tts/3/advanced" if channel == "voice" else "/sms/3/messages")
+ assert request.method == "POST"
+ assert request.headers == {
+ "Authorization": " ".join(("Bearer", "provider-token")),
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ }
+ destinations = [{"to": "+15551234567", "messageId": "correlation-id"}]
+ expected = {
+ "from": "EPP",
+ "destinations": destinations,
+ "text": MESSAGE,
+ "language": "en-US",
+ "voice": {"name": "Joanna", "gender": "female"},
+ } if channel == "voice" else {
"sender": "EPP",
- "destinations": [{"to": "+15551234567", "messageId": "correlation-id"}],
+ "destinations": destinations,
"content": {"text": MESSAGE},
- }]
+ }
+ assert json.loads(request.body) == {"messages": [expected]}
+
+
+def test_infobip_contract_is_strict_and_provider_owned():
result = InfobipProvider().map_response({
"messages": [{"messageId": "message-id", "status": {"groupName": "PENDING"}}],
}, 200)
diff --git a/python/tests/test_credentials.py b/python/tests/test_credentials.py
index fffe84e..70d3093 100644
--- a/python/tests/test_credentials.py
+++ b/python/tests/test_credentials.py
@@ -13,6 +13,8 @@
import src.credentials as credentials_module
from src.config import read_config
from src.credentials import CredentialTokenService
+from src.providers.infobip import InfobipProvider
+from src.providers.soprano import SopranoProvider
from src.providers.telesign import TelesignProvider
AUTH = {"mode": "apiKey", "key_vault_secret_name": "key", "identity_key_vault_secret_name": "id"}
@@ -121,7 +123,8 @@ def test_empty_secrets_do_not_return_a_previous_or_partial_bundle(empty_secret):
manager.close()
-def test_oauth_calls_one_sdk_client_per_request_and_only_the_sdk_requests_assertions(monkeypatch):
+@pytest.mark.parametrize("provider", [SopranoProvider(), InfobipProvider()])
+def test_oauth_calls_one_sdk_client_per_request_and_only_the_sdk_requests_assertions(monkeypatch, provider):
identity = Mock(spec=["get_token", "close"], get_token=Mock(return_value=token("PRIVATE-ASSERTION")))
create_identity = Mock(return_value=identity)
monkeypatch.setattr(credentials_module, "ManagedIdentityCredential", create_identity)
@@ -144,8 +147,8 @@ def get_token_info(scope):
try:
create_identity.assert_not_called()
create_credential.assert_not_called()
- assert manager.resolve({"mode": "oauth"}, OAUTH)["access_token"] == "PRIVATE-TOKEN-1"
- assert manager.resolve({"mode": "oauth"}, OAUTH)["access_token"] == "PRIVATE-TOKEN-2"
+ assert manager.get_credentials(provider, OAUTH)["access_token"] == "PRIVATE-TOKEN-1"
+ assert manager.get_credentials(provider, OAUTH)["access_token"] == "PRIVATE-TOKEN-2"
create_identity.assert_called_once()
create_credential.assert_called_once()
identity.get_token.assert_called_once()
diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py
index 91f2496..b9a04e5 100644
--- a/python/tests/test_function_app.py
+++ b/python/tests/test_function_app.py
@@ -183,6 +183,7 @@ def test_non_object_risk_context_fails_before_provider_work(caplog, mode, risk_c
@pytest.mark.parametrize(("provider", "mode"), [
("telesign", "apiKey"),
("soprano", "oauth"),
+ ("infobip", "oauth"),
])
def test_only_live_requests_acquire_credentials(monkeypatch, provider, mode):
monkeypatch.setenv("EPP_PROVIDER_NAME", provider)
@@ -192,8 +193,10 @@ def test_only_live_requests_acquire_credentials(monkeypatch, provider, mode):
function_app._credentials.get_credentials.return_value = {
"mode": mode, "secret": "key", "identity": "customer", "access_token": "token",
}
- provider_module.requests.request.return_value = Mock(
- status_code=200, json=Mock(return_value={"status": {"code": 3001} if mode == "apiKey" else "ENROUTE"}))
+ payload = {"messages": [{"status": {"groupName": "PENDING"}}]} if provider == "infobip" else {
+ "status": {"code": 3001} if mode == "apiKey" else "ENROUTE",
+ }
+ provider_module.requests.request.return_value = Mock(status_code=200, json=Mock(return_value=payload))
response = _HANDLER(_request(_envelope(mode=2)))
assert response.status_code == 200 and json.loads(response.get_body())["nonce"] == _NONCE
function_app._credentials.get_credentials.assert_not_called()
@@ -370,7 +373,7 @@ def test_request_selects_either_channel_regardless_of_legacy_setting(monkeypatch
("sinch", {"id": "provider-id"}, "/xms/v1/plan/batches", "/calling/v1/callouts"),
])
def test_request_channel_selects_distinct_configured_endpoints(monkeypatch, provider, payload, sms_path, voice_path):
- mode = "oauth" if provider == "soprano" else "apiKey"
+ mode = "oauth" if provider in ("soprano", "infobip") else "apiKey"
monkeypatch.setenv("EPP_PROVIDER_NAME", provider)
monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", mode)
monkeypatch.setenv("EPP_PROVIDER_SMS_ENDPOINT", "https://sms.provider.example/send")
@@ -394,6 +397,8 @@ def test_request_channel_selects_distinct_configured_endpoints(monkeypatch, prov
assert response.status_code == 200
assert json.loads(response.get_body())["nonce"] == _NONCE
assert provider_module.requests.request.call_args.args[1] == endpoint + path
+ if provider == "infobip":
+ assert provider_module.requests.request.call_args.kwargs["headers"]["Authorization"] == " ".join(("Bearer", "token"))
assert provider_module.requests.request.call_count == 4
diff --git a/setup/docs/README.md b/setup/docs/README.md
index 7fbaa1b..f86c860 100644
--- a/setup/docs/README.md
+++ b/setup/docs/README.md
@@ -23,7 +23,7 @@ Setup creates **one regional endpoint**. See [Front Door](../../docs/FRONTDOOR.m
## Availability
-Choose **Soprano or Telesign** and **FC1/EP1** hosting. Setup detects the tenant's replication scope
+Choose **Infobip, Soprano, or Telesign** and **FC1/EP1** hosting. Setup detects the tenant's replication scope
and selects the **EU** provider route for EU tenants or **Global** otherwise, without a scope picker.
**JavaScript** is the default language, without a prompt. To use another language, run
`.\Setup-Epp.ps1 -Language csharp` or `.\Setup-Epp.ps1 -Language python`.
@@ -34,13 +34,16 @@ See [source/package selection](#source-versioning).
Both channels are configured. Profile objects `deployment.routes.global` and
`deployment.routes.eu` define `smsEndpoint`, `voiceEndpoint`, tenant, authentication, and timing
values. Setup writes the endpoints to `EPP_PROVIDER_SMS_ENDPOINT` and
-`EPP_PROVIDER_VOICE_ENDPOINT`; Soprano also supplies its API app ID and scope.
+`EPP_PROVIDER_VOICE_ENDPOINT`; Infobip and Soprano also supply their API app ID and scope.
+Infobip's settings are HTTPS base URLs: its adapters append the tested `/sms/3/messages` and
+`/tts/3/advanced` paths without changing their request or response contracts.
Use a package that supports channel-specific settings; neither channel uses the other channel's URL.
-The current profiles use identical Global/EU URLs, and Soprano uses the same app ID/scope.
-The route label is not a residency or Azure-region selection. Infobip and Sinch are bundled
-adapters without guided profiles.
+Soprano and Telesign currently use identical Global/EU URLs. Infobip uses `https://api.infobip.com`
+for Global and `https://api-eu.infobip.com` for EU. Each OAuth provider uses the same API app ID/scope
+across its two routes. The route label is not a residency or Azure-region selection.
+Sinch remains a bundled adapter without a guided profile.
### Tenant scope and Azure region recommendations
@@ -107,7 +110,7 @@ For the first deployment, use a nonproduction tenant and subscription.
**Create during guided setup:** press Enter to accept **Create a new application** in the application picker,
press Enter to accept **EPP Application** or enter a display name (1-256 characters), and choose
-a provider by name from the alphabetized list (**Soprano, Telesign**). Blank provider-name input
+a provider by name from the alphabetized list (**Infobip, Soprano, Telesign**). Blank provider-name input
prompts again; there is no default provider. An explicit `-Provider` skips that provider prompt.
The new registration is only planned at this point: setup creates it after final deployment
approval, not when you choose the menu option. Canceling before approval creates nothing.
@@ -124,7 +127,7 @@ To create it manually before setup:
supply its client ID, not the application's object ID. Setup never silently creates a
replacement if an existing ID is invalid.
3. Complete provider purchase, sender registration, and API onboarding.
- Telesign uses `telesign-api-key` and `telesign-customer-id` in Key Vault. Soprano uses OAuth
+ Telesign uses `telesign-api-key` and `telesign-customer-id` in Key Vault. Infobip and Soprano use OAuth
client-assertion exchange with the selected provider tenant/scope/application ID. Setup does not
grant provider API consent or application roles. Finish the
[provider authentication handoff](../../docs/ONBOARDING.md#complete-provider-authentication)
@@ -141,7 +144,7 @@ Setup configures:
- The app's enterprise application with assignment required, `Epp.Invoke`, and its assignment to
the Microsoft phone-provider service principal.
- That Microsoft principal's tenant-wide Graph `Application.Read.All`, identifier URI, public JWE certificate, and Easy Auth caller restriction.
-- The disclosed outbound managed-identity federated credential for Soprano.
+- The disclosed outbound managed-identity federated credential for Infobip or Soprano.
## Prerequisites for Step 2
@@ -423,7 +426,7 @@ Subscription-wide registrations are not undone after later failures. See
[registration recovery](Troubleshooting.md#a-required-azure-resource-provider-is-not-registered).
Supply known values to shorten the prompts. Replace `` with your selected guided
-provider's ID (`soprano` or `telesign`); neither is a default or recommendation:
+provider's ID (`infobip`, `soprano`, or `telesign`); none is a default or recommendation:
```powershell
.\Setup-Epp.ps1 `
diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md
index d28e1f5..036859d 100644
--- a/setup/docs/Troubleshooting.md
+++ b/setup/docs/Troubleshooting.md
@@ -28,11 +28,11 @@ phone numbers, OTPs, private keys, encrypted bodies, and raw responses. Keep bod
| Symptom | First checks / owner | Safeguard |
|---|---|---|
| Cannot see an offer or EPP activation/test capability | Customer onboarding owner verifies the [Security Store and feature-access gate](../../docs/ONBOARDING.md#before-purchasing-or-deploying) with provider/Microsoft support. | Do not infer eligibility from Azure Owner or a successful purchase. |
-| Setup reports success, but no OTP delivery | Finish [Telesign vault entry or Soprano provider consent](../../docs/ONBOARDING.md#complete-provider-authentication); confirm authorized testing and policy activation are separately complete. | Do not redeploy all runtimes or paste credentials into local settings. |
+| Setup reports success, but no OTP delivery | Finish [Telesign vault entry or Infobip/Soprano provider consent](../../docs/ONBOARDING.md#complete-provider-authentication); confirm authorized testing and policy activation are separately complete. | Do not redeploy all runtimes or paste credentials into local settings. |
| Endpoint returns 401/403 with no invocation | Azure operator checks Easy Auth, issuer/audience/caller restrictions, network access, and available platform/auth logs. An ordinary customer CLI token is not the allowlisted Microsoft caller. | Do not disable Easy Auth, add a test caller, or interpret rejection as readiness. |
| App failure after invocation, including 401 | Inspect correlated `request_failed` and provider-response events. A provider's 401/403 is mapped to a handler 401; this differs from Easy Auth rejection. | Do not change inbound trust to fix outbound provider authorization. |
| `decryption_failed` or unresolved key reference | Compare summary certificate/thumbprint/key ID and versioned reference; verify Function identity vault access and reference resolution, then use an authorized evaluation. | Do not export the private key, remove certificates outside approved cleanup, or assume new vault versions automatically update Entra. |
-| Provider credentials unavailable | Check secret names/enabled versions/vault access for Telesign; customer federation plus provider-side authorization/tenant/scope for Soprano. Allow RBAC propagation. | Do not assume evaluation validates outbound credentials. |
+| Provider credentials unavailable | Check secret names/enabled versions/vault access for Telesign; customer federation plus provider-side authorization/tenant/scope for Infobip or Soprano. Allow RBAC propagation. | Do not assume evaluation validates outbound credentials. |
| Timeout, 502/504, or provider acceptance without handset receipt | Separate credential, outbound HTTP, host, and caller timings; ask the provider for supported delivery evidence. Confirm locale/digit clarity for voice. | Do not blindly resend: the first request may already have been accepted. |
| No requests/traces in Application Insights | Check the exact component/workspace, UTC window, actual authorized traffic, host/worker startup, ingestion identity/network, filters/sampling/caps, and effective exporter. Use [collection guidance](../../docs/APPLICATION-INSIGHTS.md). | Do not treat an empty table/Failures view as healthy or search every runtime for a JSON request summary. |
| Requests exist but Python/.NET event query is empty | Use [runtime-specific discovery](../../docs/MONITORING.md#runtime-specific-log-discovery). Python extras/.NET scopes may not be exported; .NET host OpenTelemetry does not initialize direct worker export. | Do not apply JavaScript's JSON parser to other runtimes or promise complete dependencies/exceptions. |
@@ -412,7 +412,7 @@ Setup closes public ingress after persistent publication failure.
Confirm `EPP_PROVIDER_SMS_ENDPOINT` or `EPP_PROVIDER_VOICE_ENDPOINT` and account access.
The selected URL is required and the handler does not use the other channel's URL; deploy a supporting package.
3. Complete [provider authentication](../../docs/ONBOARDING.md#complete-provider-authentication):
- exact vault secrets for Telesign, provider-side authorization for Soprano.
+ exact vault secrets for Telesign, provider-side authorization for Infobip or Soprano.
Arrange [authorized evaluation, then controlled live checks](../../docs/ONBOARDING.md#validate-the-deployed-endpoint).
Verify acceptance and receipt separately; never blindly resend a timeout.
diff --git a/setup/providers/catalog.json b/setup/providers/catalog.json
index 0521434..3a38a9b 100644
--- a/setup/providers/catalog.json
+++ b/setup/providers/catalog.json
@@ -10,6 +10,11 @@
"id": "soprano",
"displayName": "Soprano",
"file": "soprano.json"
+ },
+ {
+ "id": "infobip",
+ "displayName": "Infobip",
+ "file": "infobip.json"
}
]
}
diff --git a/setup/providers/infobip.json b/setup/providers/infobip.json
new file mode 100644
index 0000000..db58578
--- /dev/null
+++ b/setup/providers/infobip.json
@@ -0,0 +1,28 @@
+{
+ "deployment": {
+ "enabled": true,
+ "providerName": "Infobip",
+ "tenantId": "a7d072c3-504e-4385-9029-89283084be5d",
+ "authentication": {
+ "mode": "oauth"
+ },
+ "routes": {
+ "global": {
+ "smsEndpoint": "https://api.infobip.com",
+ "voiceEndpoint": "https://api.infobip.com",
+ "appId": "d1131a30-5ed5-49b7-8602-d978c8eb1677",
+ "scope": "d1131a30-5ed5-49b7-8602-d978c8eb1677/.default",
+ "timeoutMilliseconds": 1500,
+ "retryIntervalSeconds": 30
+ },
+ "eu": {
+ "smsEndpoint": "https://api-eu.infobip.com",
+ "voiceEndpoint": "https://api-eu.infobip.com",
+ "appId": "d1131a30-5ed5-49b7-8602-d978c8eb1677",
+ "scope": "d1131a30-5ed5-49b7-8602-d978c8eb1677/.default",
+ "timeoutMilliseconds": 1500,
+ "retryIntervalSeconds": 30
+ }
+ }
+ }
+}
diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1
index 85473d3..8ebd1d0 100644
--- a/setup/support/Epp.Setup.psm1
+++ b/setup/support/Epp.Setup.psm1
@@ -1476,7 +1476,7 @@ function Show-EppPlan {
Write-Host ' - Configure Easy Auth to require HTTPS authentication and allow only the Microsoft phone-provider enterprise application.'
Show-EppCertificatePlan -Plan $Context.CertificatePlan -Names $Names
if ($Inputs.ProviderAuthentication -eq 'oauth') {
- Write-Host ' - Soprano OAuth: add a federated credential so the outbound managed identity can authenticate without a client secret.'
+ Write-Host " - $($ProviderConfiguration.DisplayName) OAuth: add a federated credential so the outbound managed identity can authenticate without a client secret."
}
else {
Write-Host ' - Telesign API key: store the required provider credentials in Key Vault; no federated credential is created.'
diff --git a/setup/tests/Applications.Tests.ps1 b/setup/tests/Applications.Tests.ps1
index 28ffbeb..e80f203 100644
--- a/setup/tests/Applications.Tests.ps1
+++ b/setup/tests/Applications.Tests.ps1
@@ -253,7 +253,7 @@ try {
Assert ($script:ApplicationReads -eq 0 -and $script:RestrictionReads -eq 0) 'A planned new application has no existing Graph configuration to read.'
Assert ($script:PlanInputs.NewApplicationDisplayName -ceq $script:ApplicationName -and -not $script:PlanInputs.ApplicationId) 'Carry the requested name, not a fake client ID, through preflight.'
Assert ($script:PlanNames.keyVault -ceq 'unit-epp-kv-') 'Disclose pending names in the approval plan.'
- Assert ($script:Messages -contains 'Provider names: Soprano, Telesign') 'Offer all guided providers alphabetically for a new app.'
+ Assert ($script:Messages -contains 'Provider names: Infobip, Soprano, Telesign') 'Offer all guided providers alphabetically for a new app.'
Assert (($script:Messages -join "`n").Contains("Create '$script:ApplicationName' after approval")) 'Show the exact application creation action in the final plan.'
Assert ($script:Answers.Count -eq 0 -and -not (Test-Path -LiteralPath $Directory)) 'Do not write deployment output before approval.'
}
@@ -347,7 +347,7 @@ try {
Assert ($script:Creates -eq 1 -and $script:Initializations -eq 0) 'Check final resource ownership before mutating app configuration or deploying Azure resources.'
Assert (Test-Path -LiteralPath (Join-Path $setup.OutputDirectory "application-$script:AppId.json")) 'Keep the new client ID available if resolved resource names conflict.'
- foreach ($provider in @('soprano', 'telesign')) {
+ foreach ($provider in @('soprano', 'telesign', 'infobip')) {
$nameAnswer = if ($provider -eq 'soprano') { '' } else { $script:ApplicationName }
$expectedName = if ($provider -eq 'soprano') { 'EPP Application' } else { $script:ApplicationName }
$applicationAnswer = if ($provider -eq 'soprano') { '' } else { 'create' }
@@ -362,6 +362,12 @@ try {
$summary = Read-EppJson $summaries[0].FullName
$record = Read-EppJson (Join-Path $setup.OutputDirectory "application-$script:AppId.json")
Assert ($record.displayName -ceq $expectedName) 'Create and persist either the accepted default name or the supplied custom name.'
+ $expectedAuth = if ($provider -eq 'telesign') { 'apiKey' } else { 'oauth' }
+ Assert ($summary.providerAuthentication -ceq $expectedAuth -and
+ $script:DeploymentParameters.providerSettings.value.EPP_PROVIDER_AUTH_MODE -ceq $expectedAuth) 'Carry the selected authentication mode through deployment and the success summary.'
+ if ($provider -eq 'infobip') {
+ Assert (($script:Messages -join "`n").Contains('Infobip OAuth: add a federated credential')) 'The approval plan must identify Infobip federation rather than another provider.'
+ }
Assert ($summary.applicationId -ceq $record.applicationId -and $summary.provider -ceq $provider -and
$summary.packageSha256 -ceq ('b' * 64)) 'Record the deployed client ID, provider, and final package hash.'
foreach ($key in $record.resources.Keys) {
diff --git a/setup/tests/Launcher.Tests.ps1 b/setup/tests/Launcher.Tests.ps1
index 3603512..9fb266a 100644
--- a/setup/tests/Launcher.Tests.ps1
+++ b/setup/tests/Launcher.Tests.ps1
@@ -11,7 +11,7 @@ $state = @{
$requiredAssets = @(
'support/Epp.Setup.psm1', 'support/Epp.Packages.ps1', 'support/Epp.Graph.ps1'
'providers/catalog.json', 'packages/catalog.json', 'infra/main.bicep', 'infra/resources.bicep'
- 'providers/telesign.json', 'providers/soprano.json'
+ 'providers/telesign.json', 'providers/soprano.json', 'providers/infobip.json'
)
function Assert($Condition, [string] $Message) {
diff --git a/setup/tests/ProviderConfiguration.Tests.ps1 b/setup/tests/ProviderConfiguration.Tests.ps1
index a3144bb..f345c82 100644
--- a/setup/tests/ProviderConfiguration.Tests.ps1
+++ b/setup/tests/ProviderConfiguration.Tests.ps1
@@ -26,6 +26,7 @@ try {
$script:ObjectId = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
$telesign = Get-EppProviderDefinition -AssetDirectory $script:AssetDirectory -Provider Telesign
$soprano = Get-EppProviderDefinition -AssetDirectory $script:AssetDirectory -Provider Soprano
+ $infobip = Get-EppProviderDefinition -AssetDirectory $script:AssetDirectory -Provider Infobip
$unknownTenant = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'
$unconfiguredRestrictions = @(
$null
@@ -117,7 +118,7 @@ try {
$selected = Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory
Assert ($selected -ceq 'telesign' -and $script:Prompts[0] -ceq 'Provider name') 'No configuration must prompt for a provider name.'
Assert (($script:Messages -join "`n").Contains('configuration is included in the deployment approval')) 'Do not configure an existing app before deployment approval.'
- Assert ($script:Messages -contains 'Provider names: Soprano, Telesign') 'List provider names alphabetically, independently of catalog order.'
+ Assert ($script:Messages -contains 'Provider names: Infobip, Soprano, Telesign') 'List provider names alphabetically, independently of catalog order.'
Assert ($script:Patches.Count -eq 0) 'Provider resolution is read-only.'
}
}
@@ -144,6 +145,11 @@ try {
Assert ($selected -ceq 'soprano' -and $script:Prompts.Count -eq 2 -and $script:Warnings.Count -eq 1 -and
$script:Prompts[0] -ceq 'Provider name') 'Require a name when choosing among multiple configured providers.'
Assert (-not (($script:Messages -join "`n") -match '\[\d+\]')) 'Configured providers must not be numbered.'
+ Reset-TestState @('Yes')
+ $script:Application.SignInAudience = 'AzureADMultipleOrgs'
+ $script:Restriction = New-TestRestriction @($infobip.TenantId)
+ Assert ((Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory) -ceq 'infobip') 'Recognize an existing Infobip tenant restriction and confirm its provider by name.'
+ Assert ($script:Prompts[0] -match 'configured for Infobip' -and $script:Patches.Count -eq 0) 'Infobip provider discovery must remain read-only.'
Reset-TestState
$selected = Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory -Provider ' TELESIGN ' -NonInteractive
Assert ($selected -ceq 'telesign' -and $script:ApplicationReads -eq 0 -and $script:Prompts.Count -eq 0) 'An explicit provider is an explicit choice; preflight validates the actual app configuration later.'
diff --git a/setup/tests/Providers.Tests.ps1 b/setup/tests/Providers.Tests.ps1
index 4a0b217..e22237f 100644
--- a/setup/tests/Providers.Tests.ps1
+++ b/setup/tests/Providers.Tests.ps1
@@ -6,7 +6,7 @@ $module = Import-Module (Join-Path $setupDirectory 'support' 'Epp.Setup.psm1') -
$directory = Join-Path ([IO.Path]::GetTempPath()) "epp-provider-tests-$([Guid]::NewGuid().ToString('N'))"
try {
New-Item -ItemType Directory -Path (Join-Path $directory 'providers') -Force | Out-Null
- foreach ($file in @('catalog.json', 'telesign.json', 'soprano.json')) {
+ foreach ($file in @('catalog.json', 'telesign.json', 'soprano.json', 'infobip.json')) {
Copy-Item -LiteralPath (Join-Path $setupDirectory 'providers' $file) -Destination (Join-Path $directory 'providers' $file)
}
& $module {
@@ -106,7 +106,7 @@ try {
Assert ($script:Prompts.Count -eq 0) 'Package resolution must not ask for raw URLs or checksums.'
function script:Invoke-RestMethod { throw 'Unexpected REST call (no network allowed).' }
- foreach ($id in @('telesign', 'soprano')) {
+ foreach ($id in @('telesign', 'soprano', 'infobip')) {
foreach ($region in @('global', 'eu')) {
$configuration = Get-EppProvider -AssetDirectory $Directory `
-Provider $id -EndpointRegion $region -NonInteractive
@@ -121,9 +121,18 @@ try {
Assert ($settings.EPP_PROVIDER_TIMEOUT_MS -ceq [string]$route.timeoutMilliseconds) 'Preserve the provider timeout.'
Assert ($settings.EPP_PROVIDER_RETRY_INTERVAL_MS -ceq [string]([long]$route.retryIntervalSeconds * 1000)) 'Preserve retry interval metadata.'
Assert ($settings.EPP_PROVIDER_AUTH_MODE -ceq $configuration.Manifest.deployment.authentication.mode) 'Preserve provider authentication.'
- if ($id -eq 'soprano') {
+ if ($configuration.AuthenticationMode -eq 'oauth') {
Assert ($settings.EPP_PROVIDER_APP_ID -ceq $route.appId -and $settings.EPP_PROVIDER_SCOPE -ceq $route.scope) 'Preserve regional OAuth settings.'
}
+ if ($id -eq 'infobip') {
+ $baseUrl = if ($region -eq 'eu') { 'https://api-eu.infobip.com' } else { 'https://api.infobip.com' }
+ Assert ($settings.EPP_PROVIDER_SMS_ENDPOINT -ceq $baseUrl -and $settings.EPP_PROVIDER_VOICE_ENDPOINT -ceq $baseUrl) 'Infobip adapters append the tested SMS and advanced voice paths to HTTPS regional base URLs.'
+ Assert ($settings.EPP_PROVIDER_AUTH_MODE -ceq 'oauth' -and
+ $settings.EPP_PROVIDER_TENANT_ID -ceq 'a7d072c3-504e-4385-9029-89283084be5d' -and
+ $settings.EPP_PROVIDER_APP_ID -ceq 'd1131a30-5ed5-49b7-8602-d978c8eb1677' -and
+ $settings.EPP_PROVIDER_SCOPE -ceq 'd1131a30-5ed5-49b7-8602-d978c8eb1677/.default') 'Use the Infobip manifest tenant/API application and its default scope for OAuth.'
+ Assert (-not $configuration.Manifest.deployment.authentication.ContainsKey('keyVaultSecretName')) 'Infobip OAuth must not require an API-key secret.'
+ }
}
}
Assert ($script:Prompts.Count -eq 0) 'Complete provider inputs must not prompt for a channel.'
@@ -211,7 +220,7 @@ try {
AssetDirectory = $SetupDirectory
SourceBaseUri = "https://raw.githubusercontent.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/$('a' * 40)/setup"
}
- foreach ($id in @('telesign', 'soprano')) {
+ foreach ($id in @('telesign', 'soprano', 'infobip')) {
foreach ($region in @('global', 'eu')) {
$script:TenantScope = if ($region -eq 'eu') { 'EU' } else { 'NA' }
Assert-Throws {