Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 5 additions & 19 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,30 +337,16 @@ Infobip uses OAuth only, with the same managed-identity client-assertion exchang
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:
Both channel settings are **complete HTTPS URLs**, used unchanged by the adapters:

| 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 <access-token>` instead of `App <api-key>`. 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.
| Provider route | `EPP_PROVIDER_SMS_ENDPOINT` | `EPP_PROVIDER_VOICE_ENDPOINT` |
| --- | --- | --- |
| Global | `https://api.infobip.com/sms/3/messages` | `https://api.infobip.com/tts/3/advanced` |
| EU | `https://api-eu.infobip.com/sms/3/messages` | `https://api-eu.infobip.com/tts/3/advanced` |

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:
Expand Down
5 changes: 0 additions & 5 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,6 @@ Guided setup supports **Infobip, Soprano, and Telesign**.
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).

### Telesign: OAuth configuration and handoff

The provider owner must complete `deployment.AccessTokenScopeTenant` and both Global/EU routes'
Expand Down
5 changes: 1 addition & 4 deletions dotnet/Src/Providers/InfobipProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ private static HttpRequestMessage CreateRequest(
var senderId = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? "Verify";
var messageId = delivery.CorrelationId ?? delivery.MessageId;
object body;
string url;

if (channel == "voice")
{
Expand All @@ -51,7 +50,6 @@ [new Destination(delivery.PhoneNumber, messageId)],
delivery.Locale ?? "en",
new Voice("Joanna", "female"))
]);
url = $"{endpoint}/tts/3/advanced";
}
else
{
Expand All @@ -62,10 +60,9 @@ [new Destination(delivery.PhoneNumber, messageId)],
[new Destination(delivery.PhoneNumber, messageId)],
new SmsContent(delivery.Message))
]);
url = $"{endpoint}/sms/3/messages";
}

var request = new HttpRequestMessage(HttpMethod.Post, url)
var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = JsonContent.Create(body),
};
Expand Down
8 changes: 4 additions & 4 deletions dotnet/tests/ContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ public void ProviderResultKeepsExistingConstructorAndDeconstructionShape()
}

[Theory]
[InlineData("https://api.infobip.com")]
[InlineData("https://api-eu.infobip.com")]
[InlineData("https://provider.example/custom/messages")]
[InlineData("https://eu.provider.example/another/path")]
public async Task InfobipOAuthRequestsKeepExactSmsAndVoiceWireContracts(string endpoint)
{
var env = new TestEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "Verify" };
Expand All @@ -171,7 +171,7 @@ public async Task InfobipOAuthRequestsKeepExactSmsAndVoiceWireContracts(string e

var sms = await new InfobipProvider().CaptureRequestAsync(
"sms", endpoint, Delivery(), credential, env);
AssertJsonRequest(sms, $"{endpoint}/sms/3/messages", "Bearer test-token");
AssertJsonRequest(sms, endpoint, "Bearer test-token");
var expectedSms = new
{
messages = new[]
Expand All @@ -188,7 +188,7 @@ public async Task InfobipOAuthRequestsKeepExactSmsAndVoiceWireContracts(string e

var voice = await new InfobipProvider().CaptureRequestAsync(
"voice", endpoint, Delivery("voice"), credential, env);
AssertJsonRequest(voice, $"{endpoint}/tts/3/advanced", "Bearer test-token");
AssertJsonRequest(voice, endpoint, "Bearer test-token");
var expectedVoice = new
{
messages = new[]
Expand Down
2 changes: 1 addition & 1 deletion dotnet/tests/SendOtpTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public async Task RequestSelectsEitherChannelRegardlessOfLegacySetting(string pr
[Theory]
[InlineData("telesign", "", "")]
[InlineData("soprano", "", "")]
[InlineData("infobip", "/sms/3/messages", "/tts/3/advanced")]
[InlineData("infobip", "", "")]
[InlineData("sinch", "/xms/v1/plan/batches", "/calling/v1/callouts")]
public async Task RequestChannelSelectsDistinctConfiguredEndpoints(string provider, string smsPath, string voicePath)
{
Expand Down
4 changes: 2 additions & 2 deletions javascript/src/functions/providers/infobip.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const provider = Object.freeze({
};
if (channel === 'voice') {
return {
url: `${endpoint}/tts/3/advanced`,
url: endpoint,
method: 'POST',
headers,
body: JSON.stringify({
Expand All @@ -35,7 +35,7 @@ const provider = Object.freeze({
};
}
return {
url: `${endpoint}/sms/3/messages`,
url: endpoint,
method: 'POST',
headers,
body: JSON.stringify({
Expand Down
12 changes: 5 additions & 7 deletions javascript/test/provider-flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,24 +230,22 @@ 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', () => {
test('Infobip uses configured URLs unchanged with OAuth 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 region of ['global', 'eu']) {
for (const channel of ['sms', 'voice']) {
const endpoint = profile.deployment.routes[region][`${channel}Endpoint`];
const request = infobip.createRequest({
...input,
channel,
endpoint: profile.deployment.routes[region][`${channel}Endpoint`],
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.url, endpoint);
assert.equal(request.method, 'POST');
assert.deepEqual(request.headers, {
Authorization: ['Bearer', 'provider-token'].join(' '),
Expand Down
2 changes: 1 addition & 1 deletion javascript/test/sendotp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ for (const provider of ['telesign', 'soprano']) {
for (const [provider, response, smsPath, voicePath] of [
['telesign', { status: { code: 3001 } }, '', ''],
['soprano', { status: 'ENROUTE' }, '', ''],
['infobip', { messages: [{ status: { groupName: 'PENDING' } }] }, '/sms/3/messages', '/tts/3/advanced'],
['infobip', { messages: [{ status: { groupName: 'PENDING' } }] }, '', ''],
['sinch', { id: 'provider-id' }, '/xms/v1/plan/batches', '/calling/v1/callouts'],
]) {
test(`${provider}: request channel selects distinct configured endpoints`, async () => {
Expand Down
4 changes: 2 additions & 2 deletions python/src/providers/infobip.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ def build_request(self, channel, endpoint, delivery, credential, env):
"language": delivery.locale or "en",
"voice": {"name": "Joanna", "gender": "female"},
}]}
return OutboundRequest("POST", f"{endpoint}/tts/3/advanced", headers, json.dumps(body))
return OutboundRequest("POST", endpoint, headers, json.dumps(body))

body = {"messages": [{
"sender": sender_id,
"destinations": [{"to": delivery.phone_number, "messageId": message_id}],
"content": {"text": delivery.message},
}]}
return OutboundRequest("POST", f"{endpoint}/sms/3/messages", headers, json.dumps(body))
return OutboundRequest("POST", endpoint, headers, json.dumps(body))

def map_response(self, payload, http_status):
messages = payload.get("messages") if isinstance(payload, dict) else None
Expand Down
12 changes: 5 additions & 7 deletions python/tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,18 @@ def test_soprano_preserves_protocol_specific_mixed_response_shapes(body, expecte
assert result.failure_reason == (None if recognized else "unrecognized_provider_status")


@pytest.mark.parametrize("region,base_url", [
("global", "https://api.infobip.com"),
("eu", "https://api-eu.infobip.com"),
])
@pytest.mark.parametrize("region", ["global", "eu"])
@pytest.mark.parametrize("channel", ["sms", "voice"])
def test_infobip_oauth_preserves_tested_contract_on_both_setup_routes(region, base_url, channel):
def test_infobip_uses_configured_urls_unchanged_with_oauth_on_both_setup_routes(region, 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"}
endpoint = profile["deployment"]["routes"][region][f"{channel}Endpoint"]
request = provider.build_request(
channel, profile["deployment"]["routes"][region][f"{channel}Endpoint"], _delivery(channel),
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.url == endpoint
assert request.method == "POST"
assert request.headers == {
"Authorization": " ".join(("Bearer", "provider-token")),
Expand Down
2 changes: 1 addition & 1 deletion python/tests/test_function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ def test_telesign_oauth_live_send_never_forwards_inbound_credentials(monkeypatch
@pytest.mark.parametrize("provider,payload,sms_path,voice_path", [
("telesign", {"status": {"code": 3001}}, "", ""),
("soprano", {"status": "ENROUTE"}, "", ""),
("infobip", {"messages": [{"status": {"groupName": "PENDING"}}]}, "/sms/3/messages", "/tts/3/advanced"),
("infobip", {"messages": [{"status": {"groupName": "PENDING"}}]}, "", ""),
("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):
Expand Down
7 changes: 3 additions & 4 deletions setup/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,12 @@ 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`; OAuth profiles 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.
All guided providers use the complete HTTPS URLs from the selected profile without appending paths.

Use a package that supports channel-specific settings; neither channel uses the other channel's URL.

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
Soprano and Telesign currently use identical Global/EU URLs. Infobip uses the `api.infobip.com`
host for Global and `api-eu.infobip.com` for EU. Each OAuth provider uses the same API app ID/scope
across its two routes, except that Telesign's OAuth values are pending provider confirmation.
The route label is not a residency or Azure-region selection.
Sinch remains a bundled adapter without a guided profile.
Expand Down
8 changes: 4 additions & 4 deletions setup/providers/infobip.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
},
"routes": {
"global": {
"smsEndpoint": "https://api.infobip.com",
"voiceEndpoint": "https://api.infobip.com",
"smsEndpoint": "https://api.infobip.com/sms/3/messages",
"voiceEndpoint": "https://api.infobip.com/tts/3/advanced",
"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",
"smsEndpoint": "https://api-eu.infobip.com/sms/3/messages",
"voiceEndpoint": "https://api-eu.infobip.com/tts/3/advanced",
"appId": "d1131a30-5ed5-49b7-8602-d978c8eb1677",
"scope": "d1131a30-5ed5-49b7-8602-d978c8eb1677/.default",
"timeoutMilliseconds": 1500,
Expand Down
2 changes: 0 additions & 2 deletions setup/tests/Providers.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,6 @@ try {
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
Expand Down
Loading