From 5c01c4ed85930fc67d6ae91697b337718c150a39 Mon Sep 17 00:00:00 2001 From: James Xian Date: Thu, 22 Oct 2026 13:03:12 -0700 Subject: [PATCH] Add Infobip OAuth to guided setup and runtimes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ff71c22-7a86-4e1d-98d6-0329620c63ec --- TECHNICAL.md | 36 +++++- docs/GETTING-STARTED.md | 2 +- docs/ONBOARDING.md | 29 +++-- docs/README.md | 2 +- dotnet/README.md | 7 +- dotnet/Src/OAuthCredentialSource.cs | 91 +++++++++++++++ dotnet/Src/Providers/InfobipProvider.cs | 30 ++--- dotnet/Src/Providers/SopranoProvider.cs | 84 +------------- dotnet/tests/ContractTests.cs | 17 +-- dotnet/tests/CredentialAcquisitionTests.cs | 33 ++++-- dotnet/tests/SendOtpTests.cs | 108 ++++++++++++------ javascript/README.md | 2 +- javascript/src/functions/providers/infobip.js | 6 +- javascript/test/provider-flow.test.js | 45 +++++++- javascript/test/sendotp.test.js | 13 ++- python/README.md | 2 +- python/src/providers/infobip.py | 6 +- python/tests/test_contract.py | 43 +++++-- python/tests/test_credentials.py | 9 +- python/tests/test_function_app.py | 11 +- setup/docs/README.md | 21 ++-- setup/docs/Troubleshooting.md | 6 +- setup/providers/catalog.json | 5 + setup/providers/infobip.json | 28 +++++ setup/support/Epp.Setup.psm1 | 2 +- setup/tests/Applications.Tests.ps1 | 10 +- setup/tests/Launcher.Tests.ps1 | 2 +- setup/tests/ProviderConfiguration.Tests.ps1 | 8 +- setup/tests/Providers.Tests.ps1 | 17 ++- 29 files changed, 464 insertions(+), 211 deletions(-) create mode 100644 dotnet/Src/OAuthCredentialSource.cs create mode 100644 setup/providers/infobip.json 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 {