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
36 changes: 34 additions & 2 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -306,10 +306,42 @@ Every deployment can serve both configured channels. The legacy `EPP_PROVIDER_CH
<a id="soprano-integration"></a>
<a id="telesign-integration"></a>

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 <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.

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
2 changes: 1 addition & 1 deletion docs/GETTING-STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 20 additions & 9 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand All @@ -104,16 +104,22 @@ Settings select code that already exists. They do not install a provider adapter
<a id="provider-credential-names"></a>
## 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).

<a id="where-to-enter-api-key-credentials-in-key-vault"></a>
### Add Telesign secrets

Expand All @@ -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.

<a id="soprano-provider-administrator-handoff"></a>
### Complete Soprano authorization
<a id="complete-soprano-authorization"></a>
### 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:
Expand All @@ -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.

<a id="validate-the-deployed-endpoint"></a>
<a id="4-package-deploy-and-verify"></a>
## Validate the endpoint
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).

Expand All @@ -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 |
Expand Down
91 changes: 91 additions & 0 deletions dotnet/Src/OAuthCredentialSource.cs
Original file line number Diff line number Diff line change
@@ -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<string, TokenCredential> _createIdentity;
private readonly Func<string, string, Func<CancellationToken, Task<string>>, 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<string, TokenCredential> createIdentity,
Func<string, string, Func<CancellationToken, Task<string>>, TokenCredential> createCredential)
{
_createIdentity = createIdentity;
_createCredential = createCredential;
}

public async Task<ProviderCredentials> 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<AccessToken> 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;
}
}
30 changes: 15 additions & 15 deletions dotnet/Src/Providers/InfobipProvider.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Azure.Core;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
Expand All @@ -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<string, TokenCredential> createIdentity,
Func<string, string, Func<CancellationToken, Task<string>>, TokenCredential> createCredential)
{
_credentials = new(createIdentity, createCredential);
}

public override string Name => "infobip";
public override string AuthenticationMode => "apiKey";
public override string AuthenticationMode => "oauth";

public override Task<ProviderResult> SendOtpAsync(
string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials,
Expand Down Expand Up @@ -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;
}
Expand All @@ -85,17 +93,9 @@ private static ProviderResult MapResponse(Response? payload, HttpStatusCode http
};
}

public override async Task<ProviderCredentials> 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<ProviderCredentials> FetchCredentialsAsync(
AppConfig config, CancellationToken cancellationToken = default) =>
_credentials.FetchAsync(config, cancellationToken);

private static (Outcome Outcome, bool Recognized) MapStatus(string? status) => status switch
{
Expand Down
Loading
Loading