diff --git a/README.md b/README.md
index ffa4dbb..a3e8427 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Guided setup deploys **one regional endpoint** using JavaScript by default, with
> Start in a separate nonproduction environment. Confirm provider access and Microsoft's approved
> test and activation process before deployment.
-## Table of Contents
+## Table of contents
- [Features](#features)
- [Requirements](#requirements)
@@ -30,6 +30,8 @@ Guided setup deploys **one regional endpoint** using JavaScript by default, with
- Use a separate test environment with a Microsoft Entra ID tenant, Azure subscription, and
unique resource name prefix. Supply a dedicated app registration or create one during guided setup.
- Check [roles, permissions, tools, regional availability, and quota](setup/docs/README.md#prerequisites-for-step-2).
+- For Telesign, complete the [OAuth profile placeholders](docs/ONBOARDING.md#telesign-oauth-configuration-and-handoff)
+ before deployment.
- Plan for Azure, telemetry, and provider charges. The [cost estimate](docs/COST-CALCULATOR.md) is not a bill.
- Use Easy Auth to allow only the approved Microsoft caller.
diff --git a/TECHNICAL.md b/TECHNICAL.md
index 99a01db..3d6e7aa 100644
--- a/TECHNICAL.md
+++ b/TECHNICAL.md
@@ -41,7 +41,7 @@ It covers capacity, shared keys, dependencies, readiness, regional recovery dril
## Implementations
| Language | Status | Folder |
-|----------|--------|--------|
+| --- | --- | --- |
| JavaScript (Node.js) | Available | [javascript/](javascript/) |
| C# (.NET isolated worker) | Available | [dotnet/](dotnet/) |
| Python (v2 model) | Available | [python/](python/) |
@@ -70,7 +70,7 @@ Guided setup already publishes the package. Use this section for manual deployme
Download the latest successful CI ZIP for your chosen language:
| Language | Download | Contents |
-|---|---|---|
+| --- | --- | --- |
| JavaScript | [epp-javascript.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest/download/epp-javascript.zip) | Application and production dependencies |
| .NET | [epp-dotnet-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest/download/epp-dotnet-source.zip) | C# Function source and project file. Build and publish before deployment. |
| Python | [epp-python-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest/download/epp-python-source.zip) | Source for Azure remote build on Linux |
@@ -81,12 +81,12 @@ Verify `SHA256SUMS.txt`, configure the runtime, settings, Key Vault, and **Easy
Successful `main` builds publish a versioned [release](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest).
PR builds publish workflow artifacts only. Packaging does not deploy or verify delivery.
-## Build ZIPs Locally
+## Build ZIPs locally
Run one packaging script from the repository root. It creates a local ZIP without changing Azure.
| Language | Root-level script | Prerequisites | ZIP in `artifacts/` |
-|---|---|---|---|
+| --- | --- | --- | --- |
| JavaScript | [package-javascript.ps1](package-javascript.ps1) | PowerShell 7+, Node.js 20 or 22 with npm, npm registry access | `epp-javascript.zip` |
| .NET | [package-dotnet.ps1](package-dotnet.ps1) | PowerShell 7+ to package. .NET 8 SDK and NuGet feed access when customers build. | `epp-dotnet-source.zip` |
| Python | [package-python.ps1](package-python.ps1) | PowerShell 7+. Azure remote build required when deploying. | `epp-python-source.zip` |
@@ -228,7 +228,7 @@ For example, `EPP_PROVIDER_NAME` becomes `config.providerName` in JavaScript,
`config.provider_name` in Python, and `config.ProviderName` in .NET.
| Variable | When needed | Value |
-|---|---|---|
+| --- | --- | --- |
| `AzureWebJobsStorage` | Functions host storage | Local sample: `UseDevelopmentStorage=true` with Azurite running. Configure Azure host storage separately for the selected plan. |
| `FUNCTIONS_WORKER_RUNTIME` | Local host / EP1 | `node`, `python`, or `dotnet-isolated`. FC1 uses `functionAppConfig.runtime` instead. Do not add this EP1 setting to FC1. |
| `EPP_DECRYPTION_KEY_PEM` | Every request | Local test PEM or base64 PEM. In Azure, use a Key Vault reference resolving to the private-key secret. Guided setup pins the PEM backing secret of its Key Vault certificate. |
@@ -245,6 +245,13 @@ For example, `EPP_PROVIDER_NAME` becomes `config.providerName` in JavaScript,
- **OAuth:** `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE`, `EPP_OUTBOUND_CLIENT_ID`, and
`EPP_OUTBOUND_MI_CLIENT_ID`.
+For OAuth, setup maps profile `deployment.AccessTokenScopeTenant` to `EPP_PROVIDER_TENANT_ID`.
+The adjacent `deployment.tenantId` remains the provider's Security Store tenant and identifies
+the provider during application discovery. If an older profile omits `AccessTokenScopeTenant`,
+setup uses `tenantId`; explicit empty, invalid, or placeholder values fail validation.
+Both provider tenants are included in the reviewed app allowlist, preserving existing entries.
+This does not change inbound Easy Auth or use request `tenantId` as a source of trust.
+
All runtimes use `ManagedIdentityCredential` for Key Vault and outbound token access. They do not
fall back to a developer's Azure CLI identity.
@@ -363,11 +370,9 @@ locale or `en-US`, and sends `beforePasswordText`, `password`, and `afterPasswor
### Telesign
-Telesign uses `telesign-customer-id` and `telesign-api-key` from Key Vault. Basic authentication is:
-
-```text
-base64(UTF8(customer-id:api-key))
-```
+Telesign uses OAuth only, with the same managed-identity client-assertion exchange and settings as
+[Soprano](#soprano). Complete the
+[Telesign profile](docs/ONBOARDING.md#telesign-oauth-configuration-and-handoff) before deployment.
The adapter sends `recipient.phone_number`, `message.text`, optional `message.language`, one
`channels[].channel`, and `correlation_id`. SMS forwards the message unchanged. Voice separates
diff --git a/docs/FRONTDOOR.md b/docs/FRONTDOOR.md
index d62c332..d29dbf2 100644
--- a/docs/FRONTDOOR.md
+++ b/docs/FRONTDOOR.md
@@ -37,7 +37,7 @@ authentication settings consistent and use the same RSA key. Match key material,
or version IDs.
| Component | Purpose |
-|---|---|
+| --- | --- |
| Front Door profile and endpoint | Provide the single hostname used by the caller. One endpoint is sufficient for regional redundancy. |
| Route | Match `/api/SendOtp` and forward the request to the EPP origin group over HTTPS. |
| Origin group | Contain the regional Function hostnames and their health, priority, weight, and latency settings. |
@@ -182,7 +182,7 @@ In the Azure portal, create a Front Door Standard/Premium profile, an endpoint,
Use these starting settings, then validate them against the required performance target:
| Setting | Starting value |
-|---|---|
+| --- | --- |
| Tier and domain | Standard, using its generated `azurefd.net` hostname |
| Origin hostnames | Each Function App's actual default hostname |
| Origin host header | That origin's Function hostname, not the Front Door hostname |
@@ -257,7 +257,7 @@ sends only inside this endpoint. Caller retries or other delivery paths can stil
caller behavior with Microsoft and obtain separate approval for sign-in or delivery tests.
| What to test | How and where to test safely | Expected outcome / acceptance | Evidence to keep |
-|---|---|---|---|
+| --- | --- | --- | --- |
| Baseline encrypted evaluation | Send synthetic encrypted `mode: 2` requests through Front Door with both origins healthy. Confirm each origin serves requests. | Matching nonce checked locally, no delivery, and agreed baseline error/latency limits met. | Offered/completed rates, latency/errors, safe correlation IDs, and serving region. Exclude nonce values and request bodies. |
| Bad caller credentials, unauthorized callers, and direct-origin access | From the test harness, try missing/invalid/wrong-audience tokens, an unauthorized app, and direct-origin requests including a spoofed `X-Azure-FDID`. Send valid evaluations before and after. | Invalid calls are rejected while valid evaluations still work. Authentication and ingress restrictions remain enabled. | Response status and sanitized access diagnostics identifying the rejection, plus successful surrounding evaluations. |
| Recovery from A to B | Follow the recovery test: isolate only approved test origin A while leaving it **enabled in Front Door**. Keep B healthy and evaluation traffic bounded. | Health-based routing to B meets agreed recovery and transition-error limits, without changing the public URL, trust, or encryption key. | Fault/probe/client timestamps, every failure/timeout, and B's request/health signals. |
diff --git a/docs/LOGGING.md b/docs/LOGGING.md
index 4eae66d..92b86cf 100644
--- a/docs/LOGGING.md
+++ b/docs/LOGGING.md
@@ -31,7 +31,7 @@ preservation. Field names differ by runtime, so verify the
A successful live request emits these events in order:
| Service event | Safe information recorded |
-|---|---|
+| --- | --- |
| `request_received` | Function invocation and available raw Microsoft trace IDs under their `x-ms-*` names. Excludes the raw body and arbitrary headers. |
| `envelope_validated` / `payload_validated` | Allowlisted body metadata: validated payload type, normalized `channel`, `evaluation` and optional `ttlSeconds`. |
| `delivery_context_decrypted` | Decryption completed. Excludes plaintext fields, JWE, and key ID. |
@@ -90,7 +90,7 @@ request ID joins events even when
Microsoft IDs are absent or change. Log names deliberately distinguish these sources:
| Log field | Source and meaning |
-|---|---|
+| --- | --- |
| `functionName` | Runtime function name: `SendOtp` in JavaScript/.NET, `send_otp` in Python. The HTTP route remains `/api/SendOtp` in every runtime. |
| `functionRequestId` | Generated by this handler. Matches `requestId` in failure responses. It is not Microsoft's request ID. |
| `functionInvocationId` | Azure Functions host invocation ID. Null for direct handler calls without a host context. |
@@ -98,7 +98,7 @@ Microsoft IDs are absent or change. Log names deliberately distinguish these sou
| `x-ms-correlation-id` | Raw selected Microsoft correlation: envelope `correlationId` first, then the header of the same name. The existing precedence is unchanged, and this never contains a generated Function ID. |
| `msCorrelationIdSource` | `envelope`, `header`, or `none`. Identifies the selected value when the envelope and header differ. |
| `providerMessageId` | Raw provider-normalized message/reference ID returned by the provider, including Telesign `reference_id`, for support lookup. Not filled from delivery or Function IDs, although a provider may echo an ID it received. |
-| `providerTenantId` | Raw configured `EPP_PROVIDER_TENANT_ID` for OAuth, not incoming envelope `tenantId`. |
+| `providerTenantId` | Configured OAuth token tenant (`EPP_PROVIDER_TENANT_ID`), not incoming envelope `tenantId`. Unlike this log field, the deployment summary's `providerTenantId` identifies the Security Store tenant. |
| `functionOutboundClientId` | Raw application's `EPP_OUTBOUND_CLIENT_ID` used for provider access, not a Microsoft request ID. Guided setup reuses the endpoint app client ID here. Its outbound purpose is distinct from the inbound audience setting. |
| `functionOutboundManagedIdentityClientId` | Raw configured `EPP_OUTBOUND_MI_CLIENT_ID` used to obtain the app assertion, not the Key Vault identity or the identity's principal/Object ID. |
| `providerEndpoint` | Validated final outbound URL without userinfo, query string, or fragment. Scheme, host/port, and API path remain visible. |
diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md
index 7f63f88..83d71cc 100644
--- a/docs/ONBOARDING.md
+++ b/docs/ONBOARDING.md
@@ -84,8 +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 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_PROVIDER_AUTH_MODE` | `oauth` for Infobip, Soprano, or Telesign | Matches the selected profile |
+| `EPP_PROVIDER_TENANT_ID` | OAuth token-issuing tenant from `deployment.AccessTokenScopeTenant` | Can differ from the Security Store `deployment.tenantId`; summary records the two as `accessTokenScopeTenant` and `providerTenantId` |
+| `EPP_PROVIDER_APP_ID`, `EPP_PROVIDER_SCOPE` | OAuth provider API application ID and resource scope ending in `/.default` | 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 |
@@ -110,7 +111,7 @@ Guided setup supports **Infobip, Soprano, and Telesign**.
| --- | --- | --- |
| 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 |
+| Telesign | OAuth client assertion through managed identity federation | Complete [OAuth configuration and handoff](#telesign-oauth-configuration-and-handoff) |
Sinch is bundled but does not have a guided setup profile. Twilio requires the
[Add Twilio](BRING-YOUR-OWN-PROVIDER.md) development process.
@@ -120,8 +121,20 @@ than API-key authentication. Its channel settings are HTTPS regional base URLs,
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'
+`appId` and `scope` in the [Telesign profile](../setup/providers/telesign.json) before deployment.
+Scopes must end in `/.default`. Keep `deployment.tenantId` as the Security Store tenant.
+
+Use a Function package supporting Telesign OAuth and complete the
+[provider-administrator handoff](#oauth-provider-administrator-handoff).
+
-### Add Telesign secrets
+### Add API-key provider secrets
+
+This applies to API-key adapters such as Sinch, not the guided OAuth providers. Use the exact
+secret names required by the selected adapter.
1. In the deployment summary, find `resources.keyVault`.
2. Open that vault in **Azure portal > Key vaults**.
@@ -129,28 +142,28 @@ message paths in the Security Store manifest. See

-4. Create `telesign-api-key` with the raw API key.
-5. Create `telesign-customer-id` with the matching raw Customer ID.
-6. Verify both secret names, enabled state, dates, and versions without viewing their values.
-7. Confirm the Function's system-assigned identity has **Key Vault Secrets User**.
+4. Create the required secret with the raw credential, such as `sinch-api-token` for Sinch.
+5. Verify its name, enabled state, dates, and version without viewing its value.
+6. Confirm the Function's system-assigned identity has **Key Vault Secrets User**.

*The screenshots contain test examples and fake values.*
-Do not encode the credential pair or enter an Authorization header. The adapter builds Basic
-authentication. Keep credentials out of commands, transcripts, settings, screenshots, and chat.
+Store raw credentials rather than an Authorization header. Keep credentials out of commands,
+transcripts, settings, screenshots, and chat.
-
-During rotation, coordinate both values and their provider validity window. Verify an approved live
-test before retiring the old pair. Evaluation does not test provider credentials.
+During rotation, coordinate credential updates and the provider validity window. Verify an approved live
+test before retiring the old credentials. Evaluation does not test provider credentials.
-### Complete Infobip or Soprano authorization
+
+
+### Complete provider OAuth authorization
Setup creates the customer-side federation. The provider administrator still needs to authorize the
-customer application and account.
+customer application and account in the configured access-token tenant.
Send identifiers only through the approved onboarding route:
@@ -158,6 +171,7 @@ Send identifiers only through the approved onboarding route:
- endpoint application client ID
- intended channels and provider URLs
- `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_APP_ID`, and `EPP_PROVIDER_SCOPE`
+- Security Store tenant ID when it differs from the access-token tenant
- outbound identity client ID and principal ID when troubleshooting federation
- provider-requested account or sender reference
diff --git a/dotnet/README.md b/dotnet/README.md
index 45401c7..f019b43 100644
--- a/dotnet/README.md
+++ b/dotnet/README.md
@@ -36,21 +36,20 @@ For local evaluation, start Azurite and replace the test-key placeholder in this
```json
{
- "IsEncrypted": false,
- "Values": {
- "AzureWebJobsStorage": "UseDevelopmentStorage=true",
- "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
- "EPP_DECRYPTION_KEY_PEM": ""
- }
+ "IsEncrypted": false,
+ "Values": {
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+ "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
+ "EPP_DECRYPTION_KEY_PEM": ""
+ }
}
```
For live delivery, use the [shared settings table](../TECHNICAL.md#configure-environment-variables)
for `EPP_PROVIDER_NAME`, both channel endpoints, authentication, and adapter-specific options.
-Keep values as strings, including `EPP_PROVIDER_TIMEOUT_MS`, for example `"1500"`.
-Replace placeholders and store credentials in the adapter's Key Vault secrets. Each selected
-channel requires a valid endpoint. An invalid or missing endpoint returns an error without using
-the other channel.
+Keep `Values` as strings, including `EPP_PROVIDER_TIMEOUT_MS`, for example `"1500"`.
+Replace the placeholders. API-key adapters store credentials in Key Vault. Each selected channel
+requires a valid endpoint; a missing or invalid endpoint never uses the other channel.
Core Tools loads `Values` into environment variables. Direct worker execution and tests do not.
Restart after edits. Use a local test PEM because Core Tools cannot resolve Key Vault references.
@@ -86,7 +85,7 @@ Keep caller authentication and public responses in the shared handler.
For deployed diagnostics, use [Application Insights](../docs/APPLICATION-INSIGHTS.md) and
[runtime-specific log discovery](../docs/MONITORING.md#runtime-specific-log-discovery).
The host's OpenTelemetry setting does not by itself initialize a direct worker exporter.
-verify actual `ILogger` collection before depending on event-based alerts.
+Verify actual `ILogger` collection before depending on event-based alerts.
`SendOtp` validates and decrypts, selects `EPP_PROVIDER_NAME`, resolves credentials, and calls the
provider. `PhoneProviderBase.SendJsonAsync` owns bounded HTTP transport and typed response
@@ -101,14 +100,14 @@ retry cooldown. Evaluation does no provider credential work. See
[credential acquisition](../TECHNICAL.md#provider-settings-and-credential-acquisition).
| Source | Purpose |
-|---|---|
+| --- | --- |
| [Program.cs](Program.cs) | Host, provider, credential service and HTTP client registration |
| [Functions/SendOtp.cs](Functions/SendOtp.cs) | HTTP handler, provider selection, credential resolution and delivery orchestration |
| [Src/AppConfig.cs](Src/AppConfig.cs) | Shared deployment settings |
| [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/OAuthCredentialSource.cs](Src/OAuthCredentialSource.cs) | Shared managed-identity assertion exchange and reusable SDK clients for Infobip, Soprano, and Telesign OAuth |
| [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/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs
index 143eef3..2d06f2e 100644
--- a/dotnet/Src/Providers/TelesignProvider.cs
+++ b/dotnet/Src/Providers/TelesignProvider.cs
@@ -1,7 +1,7 @@
+using Azure.Core;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
-using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
@@ -17,12 +17,19 @@ public sealed class TelesignProvider : PhoneProviderBase
private static readonly Regex VoicePasscodePattern = new(
@"(? _secrets = secrets;
+ public TelesignProvider() => _credentials = new();
+
+ internal TelesignProvider(
+ Func createIdentity,
+ Func>, TokenCredential> createCredential)
+ {
+ _credentials = new(createIdentity, createCredential);
+ }
public override string Name => "telesign";
- public override string AuthenticationMode => "apiKey";
+ public override string AuthenticationMode => "oauth";
public override Task SendOtpAsync(
string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials,
@@ -41,8 +48,6 @@ private static HttpRequestMessage CreateRequest(
if (delivery.PhoneNumber is null || !Regex.IsMatch(delivery.PhoneNumber, @"\A\+[1-9][0-9]{1,14}\z"))
throw new InvalidOperationException("invalid recipient");
- var authorization = "Basic " + Convert.ToBase64String(
- Encoding.UTF8.GetBytes($"{credential.Identity}:{credential.Secret}"));
var messageText = channel == "voice" ? BuildVoiceMessage(delivery.Message!) : delivery.Message;
var body = new Request(
new Recipient(delivery.PhoneNumber),
@@ -54,7 +59,7 @@ [new Channel(channel)],
{
Content = JsonContent.Create(body),
};
- request.Headers.TryAddWithoutValidation("Authorization", authorization);
+ request.Headers.Authorization = new("Bearer", credential.AccessToken);
request.Headers.Accept.ParseAdd("application/json");
return request;
}
@@ -84,21 +89,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 key = _secrets.ResolveAsync("telesign-api-key", cancellationToken);
- var identity = _secrets.ResolveAsync("telesign-customer-id", cancellationToken);
- await Task.WhenAll(key, identity).ConfigureAwait(false);
- if (string.IsNullOrWhiteSpace(key.Result)
- || string.IsNullOrWhiteSpace(identity.Result))
- throw CredentialTokenService.Unavailable();
- return new ProviderCredentials(
- AuthenticationMode,
- Secret: key.Result,
- Identity: identity.Result);
- }
+ 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 6f93bff..1907a61 100644
--- a/dotnet/tests/ContractTests.cs
+++ b/dotnet/tests/ContractTests.cs
@@ -263,12 +263,12 @@ public async Task TelesignUsesExactEppJsonContract(string channel, string? local
channel,
$"https://verify.telesign.com/epp/{channel}",
delivery,
- new ProviderCredentials("apiKey", "test-key", "test-id"),
+ new ProviderCredentials("oauth", AccessToken: "provider-token"),
new TestEnv());
AssertJsonRequest(
request,
$"https://verify.telesign.com/epp/{channel}",
- "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test-id:test-key")));
+ "Bearer provider-token");
var expectedText = channel == "voice"
? " Your code is 9, 1, 8, 2, 7, 3.\nDo not share. "
+ " Your code is 9, 1, 8, 2, 7, 3.\nDo not share. "
@@ -293,7 +293,7 @@ public async Task TelesignVoicePacesOnlySixDigitNumericRunsAndRepeatsMessage()
"voice",
"https://verify.telesign.com/epp/voice",
delivery,
- new ProviderCredentials("apiKey", "test-key", "test-id"),
+ new ProviderCredentials("oauth", AccessToken: "provider-token"),
new TestEnv());
using var body = JsonDocument.Parse(request.Body);
Assert.Equal(
@@ -306,7 +306,7 @@ public async Task TelesignVoicePacesOnlySixDigitNumericRunsAndRepeatsMessage()
public async Task TelesignValidatesRecipientAndFallsBackToMessageId()
{
var provider = new TelesignProvider();
- var credential = new ProviderCredentials("apiKey", "key", "id");
+ var credential = new ProviderCredentials("oauth", AccessToken: "provider-token");
foreach (var phoneNumber in new[] { "15551234567", "+0123", "+1", "+1234567890123456", "+123\n", "+123\r", "+12 34" })
{
var error = await Assert.ThrowsAsync(
diff --git a/dotnet/tests/CredentialAcquisitionTests.cs b/dotnet/tests/CredentialAcquisitionTests.cs
index 058e749..d699258 100644
--- a/dotnet/tests/CredentialAcquisitionTests.cs
+++ b/dotnet/tests/CredentialAcquisitionTests.cs
@@ -99,6 +99,7 @@ public async Task AcquisitionDeadlineRejectsLateResultsWithoutBlockingTheNextReq
[Theory]
[InlineData("soprano")]
[InlineData("infobip")]
+ [InlineData("telesign")]
public async Task OAuthCallsReusableSdkCredentialsForEveryRequestWithoutPrefetchingAssertions(string providerName)
{
var identityCalls = 0;
@@ -137,6 +138,7 @@ public async Task OAuthCallsReusableSdkCredentialsForEveryRequestWithoutPrefetch
[Theory]
[InlineData("soprano")]
[InlineData("infobip")]
+ [InlineData("telesign")]
public async Task RealAzureIdentitySdkRetainsItsTokenCache(string providerName)
{
var identityCalls = 0;
@@ -168,6 +170,7 @@ public async Task RealAzureIdentitySdkRetainsItsTokenCache(string providerName)
[Theory]
[InlineData("soprano")]
[InlineData("infobip")]
+ [InlineData("telesign")]
public async Task InvalidConfigurationDoesNotPoisonLaterAcquisition(string providerName)
{
var instances = 0;
@@ -193,6 +196,7 @@ private static PhoneProviderBase CreateOAuthProvider(
{
"soprano" => new SopranoProvider(createIdentity, createCredential),
"infobip" => new InfobipProvider(createIdentity, createCredential),
+ "telesign" => new TelesignProvider(createIdentity, createCredential),
_ => throw new ArgumentException("Unexpected OAuth provider", nameof(name)),
};
diff --git a/dotnet/tests/SendOtpTests.cs b/dotnet/tests/SendOtpTests.cs
index 2da9e92..2780d5b 100644
--- a/dotnet/tests/SendOtpTests.cs
+++ b/dotnet/tests/SendOtpTests.cs
@@ -135,9 +135,12 @@ private static void ConfigureOAuth(HandlerRig rig, string provider = "soprano")
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, provider == "infobip"
- ? "{\"messages\":[{\"status\":{\"groupName\":\"PENDING\"}}]}"
- : "{\"status\":\"ENROUTE\"}"));
+ rig.Http.Respond = _ => Task.FromResult(Json(201, provider switch
+ {
+ "infobip" => "{\"messages\":[{\"status\":{\"groupName\":\"PENDING\"}}]}",
+ "telesign" => "{\"status\":{\"code\":3001}}",
+ _ => "{\"status\":\"ENROUTE\"}",
+ }));
}
[Theory]
@@ -156,16 +159,7 @@ public async Task RequestSelectsEitherChannelRegardlessOfLegacySetting(string pr
ValueTask.FromResult(new AccessToken("assertion", DateTimeOffset.UtcNow.AddHours(1)))),
(_, _, _) => new TestTokenCredential((_, _) =>
ValueTask.FromResult(new AccessToken("token", DateTimeOffset.UtcNow.AddHours(1)))));
- if (provider == "soprano")
- {
- ConfigureOAuth(rig);
- }
- else
- {
- rig.Env["EPP_PROVIDER_NAME"] = provider;
- rig.Env["EPP_PROVIDER_AUTH_MODE"] = "apiKey";
- rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"status\":{\"code\":3001}}"));
- }
+ ConfigureOAuth(rig, provider);
if (legacyChannel is not null) rig.Env["EPP_PROVIDER_CHANNEL"] = legacyChannel;
foreach (var channel in new[] { "sms", "voice" })
@@ -192,9 +186,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 is "soprano" or "infobip") ConfigureOAuth(rig, provider);
+ if (provider != "sinch") ConfigureOAuth(rig, provider);
rig.Env["EPP_PROVIDER_NAME"] = provider;
- rig.Env["EPP_PROVIDER_AUTH_MODE"] = provider is "soprano" or "infobip" ? "oauth" : "apiKey";
+ rig.Env["EPP_PROVIDER_AUTH_MODE"] = provider == "sinch" ? "apiKey" : "oauth";
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";
@@ -261,8 +255,9 @@ 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_PROVIDER_NAME"] = "sinch";
+ rig.Env["SINCH_SERVICE_PLAN_ID"] = "plan";
+ rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"id\":\"provider-id\"}"));
rig.Env["EPP_KEY_VAULT_CACHE_ENABLED"] = "PRIVATE-OBSOLETE";
rig.Env["EPP_ACCESS_TOKEN_CACHE_ENABLED"] = "PRIVATE-OBSOLETE";
Assert.Equal(0, rig.Secrets.Calls);
@@ -272,7 +267,7 @@ public async Task OnlyLiveRequestsFetchSecretsAndObsoleteCacheSettingsAreIgnored
Assert.Equal(0, rig.Http.Calls);
AssertAccepted(await rig.Invoke());
AssertAccepted(await rig.Invoke());
- Assert.Equal(4, rig.Secrets.Calls);
+ Assert.Equal(2, rig.Secrets.Calls);
Assert.Equal(2, rig.Http.Calls);
}
@@ -281,6 +276,8 @@ public async Task OnlyLiveRequestsFetchSecretsAndObsoleteCacheSettingsAreIgnored
[InlineData("soprano", "PRIVATE-OBSOLETE")]
[InlineData("infobip", "false")]
[InlineData("infobip", "PRIVATE-OBSOLETE")]
+ [InlineData("telesign", "false")]
+ [InlineData("telesign", "PRIVATE-OBSOLETE")]
public async Task OAuthAcquisitionIgnoresObsoleteCacheSettingsAndSkipsEvaluation(string provider, string setting)
{
var calls = 0;
@@ -319,6 +316,7 @@ public async Task StartupWithoutProviderConfigurationKeepsEvaluationIndependent(
[InlineData("soprano", "api://provider/.default")]
[InlineData("soprano", "api://second/.default")]
[InlineData("infobip", "d1131a30-5ed5-49b7-8602-d978c8eb1677/.default")]
+ [InlineData("telesign", "api://telesign/.default")]
public async Task OAuthUsesSetupIdentitiesAndScopeForEveryRequest(string provider, string scope)
{
var scopes = new List();
@@ -345,12 +343,13 @@ public async Task OAuthUsesSetupIdentitiesAndScopeForEveryRequest(string provide
});
});
ConfigureOAuth(rig, provider);
+ rig.Env["EPP_PROVIDER_TENANT_ID"] = "alternate-token-tenant";
rig.Env["EPP_PROVIDER_SCOPE"] = scope;
AssertAccepted(await rig.Invoke("evaluation"));
Assert.Empty(applications);
foreach (var channel in new[] { "sms", "voice" })
{
- AssertAccepted(await rig.Invoke(channel: channel, deliveryOverrides: JsonSerializer.SerializeToElement(new
+ AssertAccepted(await rig.Invoke(channel: channel, tenantId: "security-store-tenant", deliveryOverrides: JsonSerializer.SerializeToElement(new
{
providerJwt = "FORGED-PAYLOAD", locale = "fr-FR",
textToVoice = new { beforePasswordText = "ignored", password = "001234", language = "override" },
@@ -374,7 +373,7 @@ public async Task OAuthUsesSetupIdentitiesAndScopeForEveryRequest(string provide
AssertAccepted(await rig.Invoke());
Assert.Single(applications);
Assert.Equal(new[] { scope, scope, scope }, scopes);
- Assert.Equal(new[] { ("provider-tenant", "calling-application") }, applications);
+ Assert.Equal(new[] { ("alternate-token-tenant", "calling-application") }, applications);
Assert.All(identities, identity => Assert.Equal("outbound-identity", identity));
Assert.Equal(3, rig.Http.Calls);
Assert.Equal(0, rig.Secrets.Calls);
@@ -392,6 +391,10 @@ public async Task OAuthUsesSetupIdentitiesAndScopeForEveryRequest(string provide
[InlineData("infobip", true, "private-assertion", 5)]
[InlineData("infobip", false, " ", 3600)]
[InlineData("infobip", false, "private-token", -1)]
+ [InlineData("telesign", true, "", 3600)]
+ [InlineData("telesign", true, "private-assertion", 5)]
+ [InlineData("telesign", false, " ", 3600)]
+ [InlineData("telesign", 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));
@@ -410,6 +413,7 @@ public async Task OAuthRejectsUnusableTokensBeforeProviderIo(string provider, bo
[Theory]
[InlineData("soprano")]
[InlineData("infobip")]
+ [InlineData("telesign")]
public async Task OAuthRecoversAfterTimeoutWithoutRestartAndNeverRetriesProviderRejection(string provider)
{
CancellationToken observed = default;
@@ -437,7 +441,8 @@ TokenCredential CreateProvider(string tenant, string application, Func Task.FromResult(Json(401, "{\"status\":\"REJECTED\"}"));
+ rig.Http.Respond = _ => Task.FromResult(Json(401,
+ provider == "telesign" ? "{\"status\":{\"code\":999}}" : "{\"status\":\"REJECTED\"}"));
AssertFailure(rig, await rig.Invoke(), 401);
Assert.Equal((2, 0), (rig.Http.Calls, rig.Secrets.Calls));
}
@@ -542,15 +547,12 @@ public async Task ResponseBodyTimeoutCancelsWithoutRetryOrSuccessNonce()
}
[Fact]
- public async Task MissingIdentityOrKeyFailsClosedBeforeHttp()
+ public async Task MissingApiKeyFailsClosedBeforeHttp()
{
using var rig = new HandlerRig();
- rig.Env["EPP_PROVIDER_NAME"] = "telesign";
- rig.Env["EPP_PROVIDER_SMS_ENDPOINT"] = "https://verify.telesign.com/epp/sms";
+ rig.Env["EPP_PROVIDER_NAME"] = "sinch";
+ rig.Env["SINCH_SERVICE_PLAN_ID"] = "plan";
rig.Env["EPP_PROVIDER_AUTH_MODE"] = "apiKey";
- rig.Secrets.Identity = "";
- AssertFailure(rig, await rig.Invoke(), 502);
- rig.Secrets.Identity = "private-api-id";
rig.Secrets.Secret = "";
AssertFailure(rig, await rig.Invoke(), 502);
Assert.Equal(0, rig.Http.Calls);
@@ -886,8 +888,9 @@ public async Task SuccessfulLifecycleLogsAllowedBodyMetadataRawOAuthIdsAndEndpoi
public async Task ApiKeyLifecycleIdentifiesKeyVaultWithoutLoggingCredentials()
{
using var rig = new HandlerRig();
- rig.Env["EPP_PROVIDER_NAME"] = "telesign";
- rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"status\":{\"code\":3001}}"));
+ rig.Env["EPP_PROVIDER_NAME"] = "sinch";
+ rig.Env["SINCH_SERVICE_PLAN_ID"] = "plan";
+ rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"id\":\"provider-id\"}"));
AssertAccepted(await rig.Invoke());
var summary = Summary(rig);
Assert.Equal("key_vault", summary.GetProperty("providerCredentialSource").GetString());
@@ -896,7 +899,7 @@ public async Task ApiKeyLifecycleIdentifiesKeyVaultWithoutLoggingCredentials()
Assert.Equal(JsonValueKind.Null, summary.GetProperty("functionOutboundClientId").ValueKind);
Assert.Equal(JsonValueKind.Null, summary.GetProperty("functionOutboundManagedIdentityClientId").ValueKind);
Assert.InRange(summary.GetProperty("providerCredentialElapsedMs").GetInt64(), 0, summary.GetProperty("elapsedMs").GetInt64());
- Assert.Equal(2, rig.Secrets.Calls);
+ Assert.Equal(1, rig.Secrets.Calls);
Assert.Equal(LiveEvents, rig.Log.Records.Select(record => record.GetProperty("eventName").GetString()));
}
@@ -1149,7 +1152,7 @@ public HandlerRig(Func? createIdentity = null,
PhoneProviderBase[] providers =
{
Infobip,
- new TelesignProvider(Secrets),
+ new TelesignProvider(createIdentity, createOAuth),
new SopranoProvider(createIdentity, createOAuth),
new SinchProvider(Secrets),
};
@@ -1221,13 +1224,12 @@ private sealed class TestSecrets : ISecretResolver
{
public int Calls { get; private set; }
public string Secret { get; set; } = "private-api-key";
- public string Identity { get; set; } = "private-api-id";
public Exception? Error { get; set; }
public Task ResolveAsync(string? name, CancellationToken cancellationToken = default)
{
Calls++;
if (Error is not null) throw Error;
- return Task.FromResult(name == "telesign-customer-id" ? Identity : Secret);
+ return Task.FromResult(Secret);
}
}
diff --git a/javascript/README.md b/javascript/README.md
index 69f4065..e273807 100644
--- a/javascript/README.md
+++ b/javascript/README.md
@@ -2,12 +2,13 @@
-[Documentation](../docs/README.md) | [Settings](#environment-configuration) | [Request behavior](#request-behavior) | [Source](#source-and-extension-points)
+[Documentation](../docs/README.md) | [Settings](#environment-configuration) | [Request behavior](#request-behavior) | [Source](#source)
A Node.js Azure Function implementing the shared [HTTP contract](../docs/CONTRACT.md) with one
provider per deployment.
-## Setup
+
+## Setup and deployment
**Sample deployment:** follow the [five-step guide](../docs/GETTING-STARTED.md), then complete
[provider authentication and validation](../docs/ONBOARDING.md#complete-provider-authentication).
@@ -36,20 +37,20 @@ the test-key placeholder in this minimal setup:
```json
{
- "IsEncrypted": false,
- "Values": {
- "AzureWebJobsStorage": "UseDevelopmentStorage=true",
- "FUNCTIONS_WORKER_RUNTIME": "node",
- "EPP_DECRYPTION_KEY_PEM": ""
- }
+ "IsEncrypted": false,
+ "Values": {
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+ "FUNCTIONS_WORKER_RUNTIME": "node",
+ "EPP_DECRYPTION_KEY_PEM": ""
+ }
}
```
For live delivery, use the [shared settings table](../TECHNICAL.md#configure-environment-variables)
for `EPP_PROVIDER_NAME`, both channel endpoints, authentication, and adapter-specific options.
Keep `Values` as strings, including `EPP_PROVIDER_TIMEOUT_MS`, for example `"1500"`.
-Replace the placeholders. Store API keys in Key Vault. Each selected channel requires a valid
-endpoint. An invalid or missing endpoint returns an error without using the other channel.
+Replace the placeholders. API-key adapters store credentials in Key Vault. Each selected channel
+requires a valid endpoint; a missing or invalid endpoint never uses the other channel.
Core Tools copies `Values` into the process environment. Direct Node processes and offline tests
do not load the file. Restart after edits. Use a local test PEM because Core Tools cannot resolve
@@ -71,7 +72,8 @@ authentication and decryption without a provider send. Live success confirms acc
For built-in message and voice mapping, see
[Infobip, Soprano, and Telesign](../TECHNICAL.md#built-in-providers).
-## Source and extension points
+
+## Source
To add Twilio, follow the [Twilio integration guide](../docs/BRING-YOUR-OWN-PROVIDER.md).
Keep caller authentication and public responses in the shared handler.
@@ -86,7 +88,7 @@ work. Acquisition is bounded to 2.5 seconds, and shutdown aborts pending request
[credential acquisition](../TECHNICAL.md#provider-settings-and-credential-acquisition).
| Source | Purpose |
-|---|---|
+| --- | --- |
| [src/functions/SendOtp.js](src/functions/SendOtp.js) | Explicit HTTP orchestration and shutdown cancellation |
| [src/functions/config.js](src/functions/config.js) | Shared deployment settings |
| [src/functions/entraPayload.js](src/functions/entraPayload.js) | Validated Microsoft Entra ID envelope and exact contract reasons |
diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js
index 3ae5d80..96cbf16 100644
--- a/javascript/src/functions/providers/telesign.js
+++ b/javascript/src/functions/providers/telesign.js
@@ -12,12 +12,8 @@ function buildVoiceMessage(message) {
const provider = Object.freeze({
name: 'telesign',
- authenticationMode: 'apiKey',
- credentialSpec: Object.freeze({
- mode: 'apiKey',
- keyVaultSecretName: 'telesign-api-key',
- identityKeyVaultSecretName: 'telesign-customer-id',
- }),
+ authenticationMode: 'oauth',
+ credentialSpec: Object.freeze({ mode: 'oauth' }),
createRequest({ channel, endpoint, delivery, credential }) {
if (!['sms', 'voice'].includes(channel)) throw new Error('unsupported channel');
@@ -37,7 +33,7 @@ const provider = Object.freeze({
url: endpoint,
method: 'POST',
headers: {
- Authorization: `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`,
+ Authorization: `Bearer ${credential.accessToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
diff --git a/javascript/test/credential-sdk.test.js b/javascript/test/credential-sdk.test.js
index cc8f3d4..b807a8f 100644
--- a/javascript/test/credential-sdk.test.js
+++ b/javascript/test/credential-sdk.test.js
@@ -10,6 +10,7 @@ const { setTimeout: delay } = require('node:timers/promises');
const pipeline = require('@azure/core-rest-pipeline');
const { createHttpHeaders } = pipeline;
const { readConfig } = require('../src/functions/config');
+const { selectProvider } = require('../src/functions/providers');
let state;
const originalLoad = Module._load;
@@ -52,7 +53,7 @@ beforeEach(() => {
assert.ok(url.pathname.endsWith('/oauth2/v2.0/token'));
current.tokenCalls++;
}
- const call = { managedIdentity, aborted: false, completed: false };
+ const call = { managedIdentity, url: request.url, body: request.body, aborted: false, completed: false };
current.requests.push(call);
try {
await delay(managedIdentity ? current.miWait : current.wait, null, { signal: request.abortSignal });
@@ -115,6 +116,30 @@ test('the real SDK reuses its token on concurrent and later requests without app
} finally { manager.close(); }
});
+test('Telesign OAuth exchanges the managed identity assertion in the configured access-token tenant', async () => {
+ const manager = new CredentialTokenService();
+ const settings = config();
+ settings.providerName = 'telesign';
+ settings.providerAuthMode = 'oauth';
+ settings.providerTenantId = '44444444-4444-4444-4444-444444444444';
+ settings.providerScope = 'api://telesign/.default';
+ const provider = selectProvider(settings.providerName);
+ try {
+ const credential = await manager.getCredentials(provider.credentialSpec, settings);
+ assert.equal(credential.mode, 'oauth');
+ assert.equal(credential.accessToken, 'PRIVATE-TOKEN');
+ const tokenRequest = state.requests.find((request) => !request.managedIdentity);
+ const tokenUrl = new URL(tokenRequest.url);
+ assert.equal(tokenUrl.origin + tokenUrl.pathname,
+ 'https://login.microsoftonline.com/44444444-4444-4444-4444-444444444444/oauth2/v2.0/token');
+ const form = new URLSearchParams(tokenRequest.body);
+ assert.equal(form.get('scope'), settings.providerScope);
+ assert.equal(form.get('client_id'), settings.outboundClientId);
+ assert.equal(form.get('client_assertion'), 'PRIVATE-ASSERTION');
+ assert.equal(state.vaultCalls, 0);
+ } finally { manager.close(); }
+});
+
test('the request deadline aborts SDK transport without imposing a retry cooldown or returning a late token', async () => {
const manager = new CredentialTokenService();
const settings = config();
diff --git a/javascript/test/provider-flow.test.js b/javascript/test/provider-flow.test.js
index d36587e..75fdaa4 100644
--- a/javascript/test/provider-flow.test.js
+++ b/javascript/test/provider-flow.test.js
@@ -307,18 +307,15 @@ test('Sinch HTTP success requires a nonblank string ID and may infer Dispatched'
test('Telesign request validates E.164 and voice pacing remains unchanged', () => {
const telesign = selectProvider('telesign');
- const voice = telesign.createRequest({ ...input, channel: 'voice' });
- assert.deepEqual(telesign.credentialSpec, {
- mode: 'apiKey',
- keyVaultSecretName: 'telesign-api-key',
- identityKeyVaultSecretName: 'telesign-customer-id',
- });
+ const credential = { mode: 'oauth', accessToken: 'provider-token' };
+ const voice = telesign.createRequest({ ...input, channel: 'voice', credential });
+ assert.deepEqual(telesign.credentialSpec, { mode: 'oauth' });
assert.equal(JSON.parse(voice.body).message.text,
' Your code is 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1.\n'
+ ' Your code is 0, 0, 1, 2, 3, 4; ref 1234567; alternate 6, 5, 4, 3, 2, 1.\n');
for (const phoneNumber of ['15551234567', '+0123', '+1', '+1234567890123456', '+123\n']) {
assert.throws(() => telesign.createRequest({
- ...input,
+ ...input, credential,
delivery: new OtpDelivery({ ...delivery, phoneNumber }),
}), /invalid recipient/);
}
@@ -339,6 +336,24 @@ test('Telesign status.code must be an integer number', () => {
}
});
+test('Telesign sends OAuth bearer credentials for both channels', () => {
+ const telesign = selectProvider('telesign');
+ assert.equal(telesign.authenticationMode, 'oauth');
+ for (const channel of ['sms', 'voice']) {
+ const request = telesign.createRequest({
+ ...input, channel, credential: { mode: 'oauth', accessToken: 'provider-token' },
+ });
+ assert.equal(request.headers.Authorization, 'Bearer provider-token');
+ assert.equal(request.url, input.endpoint);
+ const body = JSON.parse(request.body);
+ assert.deepEqual(body.recipient, { phone_number: delivery.phoneNumber });
+ assert.deepEqual(body.channels, [{ channel }]);
+ assert.equal(body.correlation_id, delivery.correlationId);
+ assert.equal(body.message.language, delivery.locale);
+ if (channel === 'sms') assert.equal(body.message.text, delivery.message);
+ }
+});
+
test('providers fail closed with fixed safe classifications', () => {
const cases = [
[selectProvider('infobip'), response(null, 200, false), 'invalid_provider_json', 502],
diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js
index fa6ca4b..07485ef 100644
--- a/javascript/test/sendotp.test.js
+++ b/javascript/test/sendotp.test.js
@@ -277,14 +277,20 @@ for (const mode of [1, 2]) {
});
}
-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';
+for (const [provider, mode] of [['sinch', 'apiKey'], ['telesign', 'oauth'], ['soprano', 'oauth'], ['infobip', 'oauth']]) {
+ test(`${provider} ${mode}: only live requests acquire credentials and obsolete cache settings are ignored`, async () => {
+ const apiKey = mode === 'apiKey';
process.env.EPP_PROVIDER_NAME = provider;
process.env.EPP_PROVIDER_AUTH_MODE = apiKey ? 'apiKey' : 'oauth';
process.env.EPP_KEY_VAULT_CACHE_ENABLED = 'PRIVATE-OBSOLETE';
process.env.EPP_ACCESS_TOKEN_CACHE_ENABLED = 'PRIVATE-OBSOLETE';
- if (apiKey) fetchMock.mock.mockImplementation(async () => ({
+ if (provider === 'sinch') {
+ process.env.SINCH_SERVICE_PLAN_ID = 'plan';
+ fetchMock.mock.mockImplementation(async () => ({
+ ok: true, status: 200, text: async () => JSON.stringify({ id: 'provider-id' }),
+ }));
+ }
+ if (provider === 'telesign') fetchMock.mock.mockImplementation(async () => ({
ok: true, status: 200, text: async () => JSON.stringify({ status: { code: 3001 } }),
}));
if (provider === 'infobip') fetchMock.mock.mockImplementation(async () => ({
@@ -297,11 +303,48 @@ for (const provider of ['telesign', 'soprano', 'infobip']) {
assert.equal(evaluated.jsonBody.nonce, baseDelivery.nonce);
assert.deepEqual([getSecret.mock.callCount(), getToken.mock.callCount(), fetchMock.mock.callCount()], [0, 0, 0]);
for (let i = 0; i < 2; i++) assert.equal((await invoke(await envelope())).status, 200);
- assert.equal(getSecret.mock.callCount(), apiKey ? 4 : 0);
+ assert.equal(getSecret.mock.callCount(), apiKey ? 2 : 0);
assert.equal(getToken.mock.callCount(), apiKey ? 0 : 2);
});
}
+test('Telesign OAuth skips startup acquisition and both live channels use only the acquired token', async () => {
+ process.env.EPP_PROVIDER_NAME = 'telesign';
+ process.env.EPP_PROVIDER_TENANT_ID = '44444444-4444-4444-4444-444444444444';
+ fetchMock.mock.mockImplementation(async () => ({
+ ok: true, status: 200, text: async () => JSON.stringify({ status: { code: 3001 } }),
+ }));
+ assert.equal(startHook, undefined);
+ assert.equal(getToken.mock.callCount(), 0);
+ assert.equal(fetchMock.mock.callCount(), 0);
+ assert.equal((await invoke(await envelope({ mode: 2 }))).status, 200);
+ for (const channel of [1, 2]) {
+ const result = await invoke(await envelope({ channel, tenantId: 'FORGED-TENANT' },
+ { ...baseDelivery, providerJwt: 'FORGED-TOKEN' }), { authorization: 'Bearer FORGED-CALLER' });
+ assert.equal(result.status, 200);
+ assert.equal(result.jsonBody.nonce, baseDelivery.nonce);
+ const request = fetchMock.mock.calls.at(-1).arguments[1];
+ assert.equal(request.headers.Authorization, 'Bearer PRIVATE-OAUTH-TOKEN');
+ assert.doesNotMatch(request.body, /FORGED/);
+ assert.equal(event('provider_selected').providerAuthMode, 'oauth');
+ assert.equal(event('provider_credential_resolution_started').providerTenantId,
+ process.env.EPP_PROVIDER_TENANT_ID);
+ }
+ assert.equal(getToken.mock.callCount(), 2);
+ assert.equal(getSecret.mock.callCount(), 0);
+});
+
+test('Telesign OAuth acquisition failure stops before provider HTTP', async () => {
+ process.env.EPP_PROVIDER_NAME = 'telesign';
+ getToken.mock.mockImplementation(async () => { throw new Error('PRIVATE-TOKEN-FAILURE'); });
+ failure(await invoke(await envelope()), 502);
+ assert.equal(event('request_failed').failureReason, 'credential_unavailable');
+ assert.equal(getToken.mock.callCount(), 1);
+ assert.equal(getSecret.mock.callCount(), 0);
+ assert.equal(fetchMock.mock.callCount(), 0);
+ assert.equal((await invoke(await envelope({ mode: 2 }))).status, 200);
+});
+
test('a credential failure is request-correlated and the next live request can recover immediately', async () => {
getToken.mock.mockImplementationOnce(async () => { throw new Error('PRIVATE-SDK-FAILURE'); });
assert.equal((await invoke(await envelope({ mode: 2 }))).status, 200);
@@ -507,7 +550,7 @@ for (const provider of ['telesign', 'soprano']) {
for (const legacyChannel of [undefined, 'sms', 'voice', 'invalid']) {
test(`${provider}: request selects either channel with legacy setting ${legacyChannel}`, async () => {
process.env.EPP_PROVIDER_NAME = provider;
- process.env.EPP_PROVIDER_AUTH_MODE = provider === 'telesign' ? 'apiKey' : 'oauth';
+ process.env.EPP_PROVIDER_AUTH_MODE = 'oauth';
if (legacyChannel !== undefined) process.env.EPP_PROVIDER_CHANNEL = legacyChannel;
fetchMock.mock.mockImplementation(async () => ({
ok: true,
@@ -545,7 +588,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: ['soprano', 'infobip'].includes(provider) ? 'oauth' : 'apiKey',
+ EPP_PROVIDER_AUTH_MODE: provider === 'sinch' ? 'apiKey' : 'oauth',
EPP_PROVIDER_SMS_ENDPOINT: 'https://sms.provider.example/send',
EPP_PROVIDER_VOICE_ENDPOINT: 'https://voice.provider.example/send',
SINCH_SERVICE_PLAN_ID: 'plan',
@@ -744,7 +787,7 @@ test('Sinch successful HTTP without a nonblank message ID fails closed', async (
test('Telesign strict integer status and voice pacing keep endpoint outcomes stable', async () => {
process.env.EPP_PROVIDER_NAME = 'telesign';
- process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey';
+ process.env.EPP_PROVIDER_AUTH_MODE = 'oauth';
process.env.EPP_PROVIDER_VOICE_ENDPOINT = 'https://verify.telesign.com/epp/send';
for (const [code, status] of [[290, 200], ['290', 502], [true, 502]]) {
fetchMock.mock.mockImplementation(async () => ({
diff --git a/python/README.md b/python/README.md
index 6a42d3f..81815ed 100644
--- a/python/README.md
+++ b/python/README.md
@@ -36,20 +36,20 @@ For local evaluation, start Azurite and replace the test-key placeholder in this
```json
{
- "IsEncrypted": false,
- "Values": {
- "AzureWebJobsStorage": "UseDevelopmentStorage=true",
- "FUNCTIONS_WORKER_RUNTIME": "python",
- "EPP_DECRYPTION_KEY_PEM": ""
- }
+ "IsEncrypted": false,
+ "Values": {
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+ "FUNCTIONS_WORKER_RUNTIME": "python",
+ "EPP_DECRYPTION_KEY_PEM": ""
+ }
}
```
For live delivery, use the [shared settings table](../TECHNICAL.md#configure-environment-variables)
for `EPP_PROVIDER_NAME`, both channel endpoints, authentication, and adapter-specific options.
-Keep values as strings, including `EPP_PROVIDER_TIMEOUT_MS: "1500"`. Replace the placeholders.
-Store API keys in provider-named Key Vault secrets. Each selected channel requires a valid
-endpoint. An invalid or missing endpoint returns an error without using the other channel.
+Keep `Values` as strings, including `EPP_PROVIDER_TIMEOUT_MS`, for example `"1500"`.
+Replace the placeholders. API-key adapters store credentials in Key Vault. Each selected channel
+requires a valid endpoint; a missing or invalid endpoint never uses the other channel.
Core Tools loads `Values` into `os.environ`. Direct Python and pytest do not. Restart after edits.
Use a local test PEM because Core Tools cannot resolve Key Vault references locally. Keep Azurite
@@ -88,7 +88,7 @@ blocking process shutdown. Timed-out results are discarded; SDK network timeouts
underlying calls. See [credential acquisition](../TECHNICAL.md#provider-settings-and-credential-acquisition).
| Source | Purpose |
-|---|---|
+| --- | --- |
| [function_app.py](function_app.py) | Typed request orchestration and direct provider selection |
| [src/config.py](src/config.py) | Shared deployment settings |
| [src/models.py](src/models.py) | Typed Microsoft Entra ID payload, delivery context, provider request and result dataclasses |
diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py
index b293eae..4522154 100644
--- a/python/src/providers/telesign.py
+++ b/python/src/providers/telesign.py
@@ -1,4 +1,3 @@
-import base64
import json
import re
@@ -21,15 +20,11 @@ def _build_voice_message(message):
class TelesignProvider(PhoneProviderBase):
name = "telesign"
- authentication_mode = "apiKey"
+ authentication_mode = "oauth"
@property
def credential_spec(self):
- return {
- "mode": self.authentication_mode,
- "key_vault_secret_name": "telesign-api-key",
- "identity_key_vault_secret_name": "telesign-customer-id",
- }
+ return {"mode": self.authentication_mode}
def build_request(self, channel, endpoint, delivery, credential, env):
if channel not in ("sms", "voice"):
@@ -38,8 +33,6 @@ def build_request(self, channel, endpoint, delivery, credential, env):
r"\+[1-9][0-9]{1,14}", delivery.phone_number
):
raise ValueError("invalid recipient")
- raw = f"{credential['identity']}:{credential['secret']}".encode()
- authorization = "Basic " + base64.b64encode(raw).decode()
correlation_id = delivery.correlation_id
if not isinstance(correlation_id, str) or not correlation_id:
correlation_id = delivery.message_id
@@ -55,7 +48,7 @@ def build_request(self, channel, endpoint, delivery, credential, env):
"correlation_id": correlation_id,
}
headers = {
- "Authorization": authorization,
+ "Authorization": "Bearer " + credential["access_token"],
"Content-Type": "application/json",
"Accept": "application/json",
}
diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py
index e420112..84a27f4 100644
--- a/python/tests/test_contract.py
+++ b/python/tests/test_contract.py
@@ -1,4 +1,3 @@
-import base64
import json
from pathlib import Path
@@ -142,21 +141,24 @@ def test_sinch_requires_a_provider_message_id_for_success():
@pytest.mark.parametrize("channel", ["sms", "voice"])
def test_telesign_request_contract(channel):
- request = TelesignProvider().build_request(
+ provider = TelesignProvider()
+ request = provider.build_request(
channel,
f"https://verify.telesign.com/epp/{channel}",
_delivery(channel),
- {"mode": "apiKey", "secret": "key", "identity": "customer"},
+ {"mode": "oauth", "access_token": "provider-token"},
{},
)
- assert request.headers["Authorization"] == (
- "Basic " + base64.b64encode(b"customer:key").decode())
+ assert request.headers["Authorization"] == "Bearer provider-token"
+ assert provider.credential_spec == {"mode": "oauth"}
body = json.loads(request.body)
assert body["recipient"] == {"phone_number": "+15551234567"}
assert body["channels"] == [{"channel": channel}]
assert body["correlation_id"] == "correlation-id"
if channel == "voice":
assert "9, 1, 8, 2, 7, 3" in body["message"]["text"]
+ else:
+ assert body["message"]["text"] == MESSAGE
def test_telesign_enforces_integer_status_codes():
@@ -181,7 +183,7 @@ def test_http_errors_override_provider_acceptance():
def test_telesign_validates_recipient_and_channel():
provider = TelesignProvider()
- credential = {"identity": "customer", "secret": "key"}
+ credential = {"mode": "oauth", "access_token": "provider-token"}
for destination in ("15551234567", "+0123", "+1", "+1234567890123456", None):
delivery = _delivery()
delivery = OtpDelivery(
diff --git a/python/tests/test_credential_sdk.py b/python/tests/test_credential_sdk.py
index fb4a0a5..2d2c2be 100644
--- a/python/tests/test_credential_sdk.py
+++ b/python/tests/test_credential_sdk.py
@@ -9,12 +9,17 @@
import src.credentials as credentials_module
from src.config import read_config
from src.credentials import CredentialTokenService
+from src.providers.soprano import SopranoProvider
+from src.providers.telesign import TelesignProvider
@pytest.mark.parametrize("refresh_in", [None, 60])
-def test_real_sdk_reuses_tokens_and_renews_expired_tokens_on_request(monkeypatch, refresh_in):
+@pytest.mark.parametrize("provider", [SopranoProvider(), TelesignProvider()], ids=["soprano", "telesign"])
+def test_real_sdk_reuses_tokens_and_renews_expired_tokens_on_request(monkeypatch, refresh_in, provider):
token_endpoint_calls = []
mi_calls = []
+ token_tenant = "44444444-4444-4444-4444-444444444444"
+ authority = f"https://login.microsoftonline.com/{token_tenant}"
def response(payload):
result = requests.Response()
@@ -27,6 +32,10 @@ def response(payload):
def send(_session, method, url, **kwargs):
if method == "POST" and url.endswith("/oauth2/v2.0/token"):
+ assert url == f"{authority}/oauth2/v2.0/token"
+ assert kwargs["data"]["scope"] == "api://provider/.default"
+ assert kwargs["data"]["client_id"] == "22222222-2222-2222-2222-222222222222"
+ assert kwargs["data"]["client_assertion"] == "PRIVATE-ASSERTION"
token_endpoint_calls.append(url)
time.sleep(0.03)
payload = {"access_token": "PRIVATE-PROVIDER", "expires_in": 3600, "token_type": "Bearer"}
@@ -34,10 +43,11 @@ def send(_session, method, url, **kwargs):
payload["refresh_in"] = refresh_in
return response(payload)
if method == "GET" and ".well-known/openid-configuration" in url:
+ assert url.startswith(authority + "/")
return response({
- "token_endpoint": "https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/oauth2/v2.0/token",
- "authorization_endpoint": "https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/oauth2/v2.0/authorize",
- "issuer": "https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/v2.0",
+ "token_endpoint": f"{authority}/oauth2/v2.0/token",
+ "authorization_endpoint": f"{authority}/oauth2/v2.0/authorize",
+ "issuer": f"{authority}/v2.0",
})
raise AssertionError("Unexpected HTTP in fake credential transport")
@@ -54,7 +64,7 @@ def managed(*args, **kwargs):
monkeypatch.setattr(time, "time", lambda: now)
manager = CredentialTokenService(secrets, clock=lambda: now)
config = read_config({
- "EPP_PROVIDER_TENANT_ID": "11111111-1111-1111-1111-111111111111",
+ "EPP_PROVIDER_TENANT_ID": token_tenant,
"EPP_OUTBOUND_CLIENT_ID": "22222222-2222-2222-2222-222222222222",
"EPP_OUTBOUND_MI_CLIENT_ID": "33333333-3333-3333-3333-333333333333",
"EPP_PROVIDER_SCOPE": "api://provider/.default",
@@ -62,15 +72,15 @@ def managed(*args, **kwargs):
try:
create_identity.assert_not_called()
assert not token_endpoint_calls
- assert manager.resolve({"mode": "oauth"}, config)["access_token"] == "PRIVATE-PROVIDER"
+ assert manager.get_credentials(provider, config)["access_token"] == "PRIVATE-PROVIDER"
with ThreadPoolExecutor(max_workers=10) as pool:
- results = list(pool.map(lambda _: manager.resolve({"mode": "oauth"}, config), range(10)))
+ results = list(pool.map(lambda _: manager.get_credentials(provider, config), range(10)))
assert all(value["access_token"] == "PRIVATE-PROVIDER" for value in results)
assert len(token_endpoint_calls) == 1
assert len(mi_calls) == 1
- assert manager.resolve({"mode": "oauth"}, config)["access_token"] == "PRIVATE-PROVIDER"
+ assert manager.get_credentials(provider, config)["access_token"] == "PRIVATE-PROVIDER"
now += 3601
- assert manager.resolve({"mode": "oauth"}, config)["access_token"] == "PRIVATE-PROVIDER"
+ assert manager.get_credentials(provider, config)["access_token"] == "PRIVATE-PROVIDER"
assert len(token_endpoint_calls) == 2
assert len(mi_calls) == 2
create_identity.assert_called_once()
diff --git a/python/tests/test_credentials.py b/python/tests/test_credentials.py
index 70d3093..2fb2a5a 100644
--- a/python/tests/test_credentials.py
+++ b/python/tests/test_credentials.py
@@ -14,6 +14,7 @@
from src.config import read_config
from src.credentials import CredentialTokenService
from src.providers.infobip import InfobipProvider
+from src.providers.sinch import SinchProvider
from src.providers.soprano import SopranoProvider
from src.providers.telesign import TelesignProvider
@@ -123,7 +124,7 @@ def test_empty_secrets_do_not_return_a_previous_or_partial_bundle(empty_secret):
manager.close()
-@pytest.mark.parametrize("provider", [SopranoProvider(), InfobipProvider()])
+@pytest.mark.parametrize("provider", [SopranoProvider(), InfobipProvider(), TelesignProvider()])
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)
@@ -218,10 +219,10 @@ def test_invalid_configuration_does_not_poison_later_requests():
with pytest.raises(ValueError, match="unavailable"):
manager.resolve(spec, CONFIG)
secrets.resolve.assert_not_called()
- provider = TelesignProvider()
+ provider = SinchProvider()
manager.get_credentials(provider, CONFIG)
manager.get_credentials(provider, CONFIG)
- assert secrets.resolve.call_count == 4
+ assert secrets.resolve.call_count == 2
finally:
manager.close()
diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py
index b9a04e5..e3c7ccd 100644
--- a/python/tests/test_function_app.py
+++ b/python/tests/test_function_app.py
@@ -181,21 +181,25 @@ def test_non_object_risk_context_fails_before_provider_work(caplog, mode, risk_c
@pytest.mark.parametrize(("provider", "mode"), [
- ("telesign", "apiKey"),
+ ("sinch", "apiKey"),
+ ("telesign", "oauth"),
("soprano", "oauth"),
("infobip", "oauth"),
])
def test_only_live_requests_acquire_credentials(monkeypatch, provider, mode):
monkeypatch.setenv("EPP_PROVIDER_NAME", provider)
monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", mode)
+ monkeypatch.setenv("SINCH_SERVICE_PLAN_ID", "plan")
for name in ("EPP_KEY_VAULT_CACHE_ENABLED", "EPP_ACCESS_TOKEN_CACHE_ENABLED"):
monkeypatch.setenv(name, "PRIVATE-OBSOLETE")
function_app._credentials.get_credentials.return_value = {
"mode": mode, "secret": "key", "identity": "customer", "access_token": "token",
}
payload = {"messages": [{"status": {"groupName": "PENDING"}}]} if provider == "infobip" else {
- "status": {"code": 3001} if mode == "apiKey" else "ENROUTE",
+ "status": {"code": 3001} if provider == "telesign" else "ENROUTE",
}
+ if provider == "sinch":
+ payload = {"id": "provider-id"}
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
@@ -208,7 +212,8 @@ def test_only_live_requests_acquire_credentials(monkeypatch, provider, mode):
def test_credential_failure_is_request_correlated_and_next_request_recovers(monkeypatch, caplog):
caplog.set_level(logging.INFO)
- monkeypatch.setenv("EPP_PROVIDER_NAME", "telesign")
+ monkeypatch.setenv("EPP_PROVIDER_NAME", "sinch")
+ monkeypatch.setenv("SINCH_SERVICE_PLAN_ID", "plan")
monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", "apiKey")
monkeypatch.setenv("EPP_KEY_VAULT_CACHE_ENABLED", "PRIVATE-OBSOLETE")
monkeypatch.setenv("EPP_ACCESS_TOKEN_CACHE_ENABLED", "PRIVATE-OBSOLETE")
@@ -227,9 +232,9 @@ def test_credential_failure_is_request_correlated_and_next_request_recovers(monk
secrets.resolve.side_effect = None
secrets.resolve.return_value = "key"
provider_module.requests.request.return_value = Mock(
- status_code=200, json=Mock(return_value={"status": {"code": 3001}}))
+ status_code=200, json=Mock(return_value={"id": "provider-id"}))
assert _HANDLER(_request(_envelope())).status_code == 200
- assert secrets.resolve.call_count == 4
+ assert secrets.resolve.call_count == 2
provider_module.requests.request.assert_called_once()
assert "PRIVATE" not in caplog.text
finally:
@@ -338,13 +343,12 @@ def test_jwe_tampering_fails_before_provider_work():
@pytest.mark.parametrize("provider", ["telesign", "soprano"])
@pytest.mark.parametrize("legacy_channel", [None, "sms", "voice", "invalid"])
def test_request_selects_either_channel_regardless_of_legacy_setting(monkeypatch, provider, legacy_channel):
- mode = "apiKey" if provider == "telesign" else "oauth"
monkeypatch.setenv("EPP_PROVIDER_NAME", provider)
- monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", mode)
+ monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", "oauth")
if legacy_channel is not None:
monkeypatch.setenv("EPP_PROVIDER_CHANNEL", legacy_channel)
function_app._credentials.get_credentials.return_value = {
- "mode": mode, "secret": "key", "identity": "customer", "access_token": "token",
+ "mode": "oauth", "access_token": "token",
}
provider_module.requests.request.return_value = Mock(
status_code=200,
@@ -366,6 +370,29 @@ def test_request_selects_either_channel_regardless_of_legacy_setting(monkeypatch
assert provider_module.requests.request.call_count == 4
+def test_telesign_oauth_live_send_never_forwards_inbound_credentials(monkeypatch):
+ monkeypatch.setenv("EPP_PROVIDER_NAME", "telesign")
+ provider = function_app._select_provider("telesign")
+ assert provider.authentication_mode == "oauth"
+ assert provider.credential_spec == {"mode": "oauth"}
+ function_app._credentials.get_credentials.assert_not_called()
+ provider_module.requests.request.assert_not_called()
+ provider_module.requests.request.return_value = Mock(
+ status_code=200, json=Mock(return_value={"status": {"code": 3001}}))
+ for channel in (1, 2):
+ response = _HANDLER(_request(
+ _envelope(channel=channel, tenantId="FORGED-TENANT"),
+ headers={"Authorization": "Bearer FORGED-CALLER"}))
+ assert response.status_code == 200
+ assert provider_module.requests.request.call_args.kwargs["headers"]["Authorization"] == "Bearer provider-token"
+ function_app._credentials.get_credentials.side_effect = ValueError("provider credential unavailable")
+ provider_module.requests.request.reset_mock()
+ response = _HANDLER(_request(_envelope()))
+ assert response.status_code == 502 and "nonce" not in json.loads(response.get_body())
+ provider_module.requests.request.assert_not_called()
+ assert _HANDLER(_request(_envelope(mode=2))).status_code == 200
+
+
@pytest.mark.parametrize("provider,payload,sms_path,voice_path", [
("telesign", {"status": {"code": 3001}}, "", ""),
("soprano", {"status": "ENROUTE"}, "", ""),
@@ -373,7 +400,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 in ("soprano", "infobip") else "apiKey"
+ mode = "apiKey" if provider == "sinch" else "oauth"
monkeypatch.setenv("EPP_PROVIDER_NAME", provider)
monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", mode)
monkeypatch.setenv("EPP_PROVIDER_SMS_ENDPOINT", "https://sms.provider.example/send")
diff --git a/setup/docs/README.md b/setup/docs/README.md
index f86c860..1d0f824 100644
--- a/setup/docs/README.md
+++ b/setup/docs/README.md
@@ -34,7 +34,7 @@ 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`; Infobip and Soprano also supply their API app ID and scope.
+`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.
@@ -42,9 +42,29 @@ Use a package that supports channel-specific settings; neither channel uses the
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.
+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.
+### Provider tenant configuration
+
+Provider profiles distinguish these adjacent fields under `deployment`:
+
+| Field | Purpose |
+| --- | --- |
+| `tenantId` | Provider's Security Store tenant; used for provider discovery and recorded as `providerTenantId` in the deployment summary |
+| `AccessTokenScopeTenant` | OAuth token-issuing tenant; mapped to `EPP_PROVIDER_TENANT_ID` and recorded as `accessTokenScopeTenant` |
+
+The token tenant can differ from the Security Store tenant. For older profiles that omit
+`AccessTokenScopeTenant`, setup uses `tenantId`; explicit empty, invalid, or placeholder values
+are rejected. OAuth setup adds both tenants to the reviewed application allowlist, deduplicates
+equal values, and preserves existing allowed tenants. The application home tenant remains allowed.
+Easy Auth still admits only the approved Microsoft caller.
+
+Telesign's profile contains placeholders for the token tenant and regional API app IDs/scopes.
+Complete [Telesign configuration](../../docs/ONBOARDING.md#telesign-oauth-configuration-and-handoff)
+before deployment.
+
### Tenant scope and Azure region recommendations
Setup reads `tenant_region_scope` from the selected tenant's public-cloud OpenID Connect discovery
@@ -76,7 +96,7 @@ Review the [Azure hosting and provider cost estimate](../../docs/COST-CALCULATOR
directly from the service-plan prompt.
| Option | Hosting |
-|---|---|
+| --- | --- |
| 1. Flex Consumption (FC1) | On-demand with a free usage grant; zero always-ready instances, 2048 MB, up to 40 on-demand instances |
| 2. Premium (EP1) | Existing Premium configuration with one warm instance |
@@ -127,8 +147,8 @@ 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. Infobip and Soprano use OAuth
- client-assertion exchange with the selected provider tenant/scope/application ID. Setup does not
+ Infobip, Soprano, and Telesign OAuth use client-assertion exchange with the selected access-token
+ tenant/scope/application ID. Complete Telesign's profile placeholders first. Setup does not
grant provider API consent or application roles. Finish the
[provider authentication handoff](../../docs/ONBOARDING.md#complete-provider-authentication)
after setup creates the vault and identity identifiers needed for those steps.
@@ -140,11 +160,11 @@ Creation is an interactive choice. Unattended runs still require an existing `-A
Setup configures:
- Organizational multitenancy and the Microsoft Entra ID allowed-tenants preview: home tenant,
- selected provider tenant, and preserved existing allowed provider tenants.
+ selected provider's Security Store and OAuth token tenants, and preserved existing allowed tenants.
- 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 Infobip or Soprano.
+- The disclosed outbound managed-identity federated credential for OAuth providers.
## Prerequisites for Step 2
@@ -168,7 +188,7 @@ Setup configures:
restricted, an appropriate role such as **Application Developer** can provide that permission.
This does not replace the other setup permissions.
See [application creation permissions](https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0#permissions).
-- Microsoft Graph **beta** access for `signInAudienceRestrictions`. The provider tenant is added;
+- Microsoft Graph **beta** access for `signInAudienceRestrictions`. The Security Store and OAuth token tenants are added;
Microsoft Entra ID always allows the home tenant.
See the
[allowed-tenants schema](https://learn.microsoft.com/en-us/graph/api/resources/allowedtenantsaudience?view=graph-rest-beta).
@@ -212,7 +232,7 @@ dotnet --list-sdks
Interactive setup can install missing Bicep.
| Choice | Azure runtime | Automatic deployment path |
-|---|---|---|
+| --- | --- | --- |
| JavaScript | Node.js 22, Functions v4 | Verify and publish the ready ZIP with its production dependencies |
| C# | .NET 8 isolated, Functions v4 | Verify source ZIP, publish for Linux with .NET 8, repackage and publish |
| Python | Python 3.11, Functions v4 | Verify source ZIP; One Deploy with remote build on FC1, or validate/download and publish the SCM-built output on EP1 |
@@ -334,7 +354,7 @@ It displays each key ID, certificate thumbprint, name, and validity. Signing/ver
certificates are not encryption candidates, and unsupported encryption credential types stop setup.
| Existing encryption credentials | Choice | Result after successful deployment |
-|---|---|---|
+| --- | --- | --- |
| None | Creation is automatic; optional `-CertificateAction Create`. | Register one certificate from the setup vault. |
| One | Enter accepts `Replace`; select `Reuse` explicitly to provide its Key Vault certificate version. | Replace the existing credential with one new credential, or keep the existing key ID when reusing. |
| Several | Enter accepts `Replace`; select `Reuse` explicitly to choose a key ID and provide its certificate version. | Replace all encryption entries with one new credential, or remove only the other encryption entries when reusing. |
@@ -559,12 +579,13 @@ configuration metadata, not provider credentials or private-key bytes. Keep it a
**Save the output: the endpoint URL and Application (client) ID are required for policy updates.**
| Summary field | How to use it |
-|---|---|
+| --- | --- |
| `tenantId`, `subscriptionId`, `applicationId` | Verify the intended customer directory, Azure subscription, and endpoint **client ID**. |
| `tenantRegionScope` | Detected tenant replication scope used to select the provider route and apply EU-only region recommendations. |
| `resources.functionApp`, `resources.keyVault`, `resources.applicationInsights`, `resources.logAnalytics` | Find the exact Azure resources; do not guess names or use a different environment's vault. |
-| `provider`, `endpointRegion`, `providerTenantId` | Confirm the tenant-derived provider route, separate from Azure location. Both SMS and voice use this configuration. |
-| `allowedProviderTenantIds` | Review the complete preserved provider-tenant allowlist, including the selected provider. This does not enable runtime switching between providers. |
+| `provider`, `endpointRegion`, `providerTenantId` | Confirm the tenant-derived provider route and Security Store tenant, separate from Azure location. Both SMS and voice use this configuration. |
+| `accessTokenScopeTenant` | OAuth token-issuing tenant, matching `EPP_PROVIDER_TENANT_ID`; can differ from `providerTenantId` and is null for API-key deployments |
+| `allowedProviderTenantIds` | Review the preserved allowlist, including the selected provider's Security Store and OAuth token tenants. This does not enable runtime switching between providers. |
| `providerSmsEndpoint`, `providerVoiceEndpoint` | Confirm the distinct or identical provider URLs for SMS and voice. These are outbound provider URLs, not the Function's `endpointUrl`. |
| `endpointUrl`, `identifierUri` | Exact SendOtp URL and registered app audience information for the authorized test/policy operator. |
| `encryptionKeyId`, `certificateThumbprint`, `certificateId`, `certificateSecretId`, `certificateExpiresUtc` | Public-key identification and version/renewal inventory. A secret **ID** is not its value; do not fetch its private-key contents. |
diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md
index 036859d..e84950a 100644
--- a/setup/docs/Troubleshooting.md
+++ b/setup/docs/Troubleshooting.md
@@ -26,13 +26,14 @@ resource group, runtime/release, UTC time, and safe correlation IDs. Exclude set
phone numbers, OTPs, private keys, encrypted bodies, and raw responses. Keep body tracing off.
| 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 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. |
+| Setup reports success, but no OTP delivery | Finish [provider OAuth authorization](../../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 Infobip or Soprano. Allow RBAC propagation. | Do not assume evaluation validates outbound credentials. |
+| Provider credentials unavailable | For guided providers, check customer federation and provider authorization in `AccessTokenScopeTenant`, plus the API scope. API-key adapters such as Sinch require their configured vault secrets. | Evaluation does not validate outbound credentials. |
+| Telesign profile is not deployment-ready | Have the provider owner complete `AccessTokenScopeTenant` and both routes' `appId`/`scope` placeholders. | Do not invent values, substitute the Security Store or customer tenant, or bypass validation. |
| 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. |
@@ -65,7 +66,7 @@ credentials and create a new certificate after final approval). Select **Reuse**
one, or **0/Cancel** to stop. Unattended runs still require an explicit **Reuse** or **Replace**.
| Failure | Safe recovery |
-|---|---|
+| --- | --- |
| Missing `CertificateAction`, `EncryptionKeyId`, or `KeyVaultCertificateId` in unattended mode | Supply an explicit action. Reuse requires the exact key ID and versioned `/certificates/` URL even when only one credential exists. `-ApproveDeployment` is not a certificate choice. |
| Key Vault certificate does not match the selected app credential | Compare the displayed key ID and public thumbprint with the exact Key Vault certificate version. A same-name or renewed certificate is not necessarily the registered certificate. Correct the location or explicitly choose replacement. |
| Expired, not-yet-valid, disabled, or incompatible certificate/secret | Select a valid certificate with more than 30 days remaining and an exportable RSA key of at least 2048 bits in a certificate-managed PEM secret. A public `.cer` file or standalone PEM secret is not a reuse location. |
@@ -412,7 +413,8 @@ 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 Infobip or Soprano.
+ provider-side authorization for OAuth, or exact vault secrets for an API-key adapter.
+ `EPP_PROVIDER_TENANT_ID` must match the access-token tenant, which can differ from the Security Store tenant.
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/soprano.json b/setup/providers/soprano.json
index cf8d348..ede3338 100644
--- a/setup/providers/soprano.json
+++ b/setup/providers/soprano.json
@@ -3,6 +3,7 @@
"enabled": true,
"providerName": "Soprano",
"tenantId": "801bae25-4443-4a29-9e56-9d1cf22ff819",
+ "AccessTokenScopeTenant": "801bae25-4443-4a29-9e56-9d1cf22ff819",
"authentication": {
"mode": "oauth"
},
diff --git a/setup/providers/telesign.json b/setup/providers/telesign.json
index 42fd335..391b047 100644
--- a/setup/providers/telesign.json
+++ b/setup/providers/telesign.json
@@ -3,21 +3,24 @@
"enabled": true,
"providerName": "Telesign",
"tenantId": "d818b557-ea1c-4070-a3f1-928330b7a30c",
+ "AccessTokenScopeTenant": "",
"authentication": {
- "mode": "apiKey",
- "keyVaultSecretName": "telesign-api-key",
- "identityKeyVaultSecretName": "telesign-customer-id"
+ "mode": "oauth"
},
"routes": {
"global": {
"smsEndpoint": "https://verify.telesign.com/integration/msft/cyot",
"voiceEndpoint": "https://verify.telesign.com/integration/msft/cyot",
+ "appId": "",
+ "scope": "/.default",
"timeoutMilliseconds": 1500,
"retryIntervalSeconds": 30
},
"eu": {
"smsEndpoint": "https://verify.telesign.com/integration/msft/cyot",
"voiceEndpoint": "https://verify.telesign.com/integration/msft/cyot",
+ "appId": "",
+ "scope": "/.default",
"timeoutMilliseconds": 1500,
"retryIntervalSeconds": 30
}
diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1
index 8ebd1d0..aeaeb27 100644
--- a/setup/support/Epp.Setup.psm1
+++ b/setup/support/Epp.Setup.psm1
@@ -291,8 +291,14 @@ function ConvertTo-EppProviderSettings {
$issues.Add('the provider owner has not enabled this profile')
}
if ($deployment['providerName'] -ine $Id) { $issues.Add('deployment.providerName must match the catalog ID or display name') }
+ $providerTenantId = $null
try { $providerTenantId = ConvertTo-EppGuid $deployment['tenantId'] }
- catch { $issues.Add('deployment.tenantId must identify the provider tenant') }
+ catch { $issues.Add('deployment.tenantId must identify the provider Security Store tenant') }
+ $accessTokenScopeTenant = $providerTenantId
+ if ($deployment.Contains('AccessTokenScopeTenant')) {
+ try { $accessTokenScopeTenant = ConvertTo-EppGuid $deployment['AccessTokenScopeTenant'] }
+ catch { $issues.Add('deployment.AccessTokenScopeTenant must identify the provider access-token tenant') }
+ }
$authentication = $deployment['authentication']
if ($authentication -isnot [Collections.IDictionary] -or $authentication['mode'] -notin @('apiKey', 'oauth')) {
@@ -360,7 +366,7 @@ function ConvertTo-EppProviderSettings {
EPP_PROVIDER_TIMEOUT_MS = [string]$selectedRoute['timeoutMilliseconds']
EPP_PROVIDER_RETRY_INTERVAL_MS = [string]([long]$selectedRoute['retryIntervalSeconds'] * 1000)
EPP_PROVIDER_AUTH_MODE = $authenticationMode
- EPP_PROVIDER_TENANT_ID = $providerTenantId
+ EPP_PROVIDER_TENANT_ID = $accessTokenScopeTenant
}
if ($authenticationMode -eq 'oauth') {
$settings.EPP_PROVIDER_SCOPE = [string]$selectedRoute['scope']
@@ -369,6 +375,8 @@ function ConvertTo-EppProviderSettings {
return [pscustomobject]@{
Id = $Id
DisplayName = $DisplayName
+ TenantId = $providerTenantId
+ AccessTokenScopeTenant = $accessTokenScopeTenant
Manifest = $Profile
EndpointRegion = [string]$regionEntry['id']
AuthenticationMode = $authenticationMode
@@ -1342,7 +1350,11 @@ function Connect-EppContext {
} else { $null }
$providerRestrictionState = Get-EppProviderRestrictionState -Restriction $tenantRestriction
$allowedTenants = @(if ($providerRestrictionState) { $providerRestrictionState.allowedTenantIds })
- $Inputs.ProviderAllowedTenantIds = @(($allowedTenants + @($Inputs.ProviderTenantId)) | Sort-Object -Unique)
+ $providerTenants = @($Inputs.ProviderTenantId)
+ if ($Inputs['ProviderAuthentication'] -eq 'oauth') {
+ $providerTenants += ConvertTo-EppGuid ([string]$Inputs['AccessTokenScopeTenant'])
+ }
+ $Inputs.ProviderAllowedTenantIds = @(($allowedTenants + $providerTenants) | Sort-Object -Unique)
$version = if ($application.Api -and $application.Api.RequestedAccessTokenVersion) { [int]$application.Api.RequestedAccessTokenVersion } else { 1 }
if ($version -notin @(1, 2)) { throw 'The endpoint application has an unsupported access-token version.' }
@@ -1413,7 +1425,7 @@ function Show-EppPlan {
$providerRoute = if ($ProviderConfiguration.EndpointRegion -eq 'eu') { 'EU' } else { 'Global' }
Write-Host "Provider route: $providerRoute"
Write-Host "Provider auth: $($ProviderConfiguration.AuthenticationMode)"
- Write-Host "Provider tenant: $($ProviderConfiguration.Settings.EPP_PROVIDER_TENANT_ID)"
+ Write-Host "Security Store tenant: $($ProviderConfiguration.TenantId)"
if ($Inputs['ProviderAllowedTenantIds']) {
Write-Host "Allowed provider tenants after deployment: $($Inputs.ProviderAllowedTenantIds -join ', ') (home tenant also allowed)"
Write-Host 'Existing allowed provider tenants are preserved; selecting a provider does not configure multi-provider runtime fallback.'
@@ -1422,6 +1434,7 @@ function Show-EppPlan {
Write-Host "Voice endpoint: $($ProviderConfiguration.Settings.EPP_PROVIDER_VOICE_ENDPOINT)"
Write-Host 'Per-channel routing requires a Function package that reads EPP_PROVIDER_SMS_ENDPOINT and EPP_PROVIDER_VOICE_ENDPOINT.' -ForegroundColor Yellow
if ($ProviderConfiguration.AuthenticationMode -eq 'oauth') {
+ Write-Host "Access-token tenant: $($ProviderConfiguration.AccessTokenScopeTenant)"
Write-Host "Provider API: $($ProviderConfiguration.Settings.EPP_PROVIDER_APP_ID) / $($ProviderConfiguration.Settings.EPP_PROVIDER_SCOPE)"
}
else {
@@ -1479,7 +1492,7 @@ function Show-EppPlan {
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.'
+ Write-Host " - $($ProviderConfiguration.DisplayName) API key: store the required provider credentials in Key Vault; no federated credential is created."
}
Write-Host "`nDeployment notes" -ForegroundColor Cyan
@@ -1568,13 +1581,11 @@ function Show-EppDeploymentResult {
Write-Host "Deployment details: $ResultPath"
Write-Host 'Certificate renewal is manual. Track the expiry in the deployment details and coordinate the Entra certificate update before expiry.' -ForegroundColor Yellow
- if ($ProviderConfiguration.Id -eq 'telesign') {
- $authentication = $ProviderConfiguration.Manifest.deployment.authentication
+ if ($ProviderConfiguration.AuthenticationMode -eq 'oauth') {
Write-Host "`nPending operation" -ForegroundColor Yellow
- Write-Host "Add the Telesign credentials to Key Vault '$($Names.keyVault)':"
- Write-Host " - $($authentication.identityKeyVaultSecretName) - your Telesign customer ID."
- Write-Host " - $($authentication.keyVaultSecretName) - your Telesign API key."
- Write-Host ' Store the values as Key Vault secrets; do not enter them into this setup script or shared logs.'
+ Write-Host "Have the $($ProviderConfiguration.DisplayName) administrator authorize application '$($Inputs.ApplicationId)' in access-token tenant '$($ProviderConfiguration.AccessTokenScopeTenant)'."
+ Write-Host " Provider API: $($ProviderConfiguration.Settings.EPP_PROVIDER_APP_ID) / $($ProviderConfiguration.Settings.EPP_PROVIDER_SCOPE)"
+ Write-Host ' Provider consent and application-role assignments are not performed by setup. No provider API-key secrets are required.'
}
Write-Host "`nNext steps" -ForegroundColor Cyan
@@ -2518,6 +2529,7 @@ function Invoke-EppDeployment {
provider = $ProviderConfiguration.Id
endpointRegion = $ProviderConfiguration.EndpointRegion; providerAuthentication = $Inputs.ProviderAuthentication
providerTenantId = $Inputs.ProviderTenantId
+ accessTokenScopeTenant = if ($Inputs.ProviderAuthentication -eq 'oauth') { $Inputs['AccessTokenScopeTenant'] } else { $null }
allowedProviderTenantIds = @($Inputs.ProviderAllowedTenantIds)
providerSmsEndpoint = $ProviderConfiguration.Settings.EPP_PROVIDER_SMS_ENDPOINT
providerVoiceEndpoint = $ProviderConfiguration.Settings.EPP_PROVIDER_VOICE_ENDPOINT
@@ -2797,7 +2809,8 @@ function Invoke-EppSetup {
$providerConfiguration = Get-EppProvider -AssetDirectory $assetDirectory -Provider $providerName `
-EndpointRegion $endpointRegion -NonInteractive:$NonInteractive
$inputs.ProviderAuthentication = $providerConfiguration.AuthenticationMode
- $inputs.ProviderTenantId = $providerConfiguration.Settings.EPP_PROVIDER_TENANT_ID
+ $inputs.ProviderTenantId = $providerConfiguration.TenantId
+ $inputs.AccessTokenScopeTenant = $providerConfiguration.AccessTokenScopeTenant
$selection = Get-EppLanguage -AssetDirectory $assetDirectory -Language $Language -SourceRepository $SourceRepository `
-PackageReleaseTag $PackageReleaseTag -NonInteractive:$NonInteractive
$inputs.Language = $selection.Id
diff --git a/setup/tests/Accounts.Tests.ps1 b/setup/tests/Accounts.Tests.ps1
index f266066..5146c66 100644
--- a/setup/tests/Accounts.Tests.ps1
+++ b/setup/tests/Accounts.Tests.ps1
@@ -332,7 +332,7 @@ exit $ExitCode
}
$setup = @{
Location = 'westus2'; ResourcePrefix = 'unit'; Language = 'javascript'; ServicePlan = 'FC1'
- Provider = 'telesign'; EndpointRegion = 'global'; OutputDirectory = $Directory
+ Provider = 'soprano'; EndpointRegion = 'global'; OutputDirectory = $Directory
AssetDirectory = $ModulePath
SourceBaseUri = "https://raw.githubusercontent.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/$('a' * 40)/setup"
}
diff --git a/setup/tests/Applications.Tests.ps1 b/setup/tests/Applications.Tests.ps1
index e80f203..77c08ab 100644
--- a/setup/tests/Applications.Tests.ps1
+++ b/setup/tests/Applications.Tests.ps1
@@ -27,6 +27,20 @@ try {
$script:ObjectId = '22222222-2222-2222-2222-222222222222'
$script:ExistingAppId = '33333333-3333-3333-3333-333333333333'
$script:ApplicationName = 'Contoso EPP'
+ $script:TokenTenant = '44444444-4444-4444-4444-444444444444'
+ $script:OriginalReadJson = ${function:Read-EppJson}
+ function script:Read-EppJson {
+ param([string] $Path)
+ $value = & $script:OriginalReadJson $Path
+ if ([IO.Path]::GetFileName($Path) -ceq 'telesign.json') {
+ $value.deployment.AccessTokenScopeTenant = $script:TokenTenant
+ foreach ($region in @('global', 'eu')) {
+ $value.deployment.routes[$region].appId = '55555555-5555-5555-5555-555555555555'
+ $value.deployment.routes[$region].scope = 'api://55555555-5555-5555-5555-555555555555/.default'
+ }
+ }
+ return $value
+ }
$script:GraphAccess = @{
EndpointPrincipalId = $script:ObjectId; CallerPrincipalId = $script:Operator; GraphPrincipalId = $script:Tenant
}
@@ -362,12 +376,20 @@ 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' }
+ $expectedAuth = '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.'
}
+ if ($provider -eq 'telesign') {
+ Assert ($summary.accessTokenScopeTenant -ceq $script:TokenTenant -and
+ $summary.providerTenantId -cne $script:TokenTenant -and
+ $script:DeploymentParameters.providerSettings.value.EPP_PROVIDER_TENANT_ID -ceq $script:TokenTenant) 'New applications must retain the Security Store tenant and use the separate token authority.'
+ Assert ($script:PlanInputs.ProviderAllowedTenantIds -contains $script:TokenTenant -and
+ $summary.allowedProviderTenantIds -contains $summary.providerTenantId -and
+ $summary.allowedProviderTenantIds -contains $script:TokenTenant) 'Review and persist both tenant identities for newly created applications.'
+ }
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/Certificates.Tests.ps1 b/setup/tests/Certificates.Tests.ps1
index adeb160..e76fa5f 100644
--- a/setup/tests/Certificates.Tests.ps1
+++ b/setup/tests/Certificates.Tests.ps1
@@ -520,6 +520,7 @@ try {
}
$provider = @{
Id = 'unit'; EndpointRegion = 'global'; AuthenticationMode = 'apiKey'
+ TenantId = $inputs.ProviderTenantId; AccessTokenScopeTenant = $inputs.ProviderTenantId
Settings = @{ EPP_PROVIDER_SMS_ENDPOINT = 'https://sms.contoso.com/send'; EPP_PROVIDER_VOICE_ENDPOINT = 'https://voice.contoso.com/send'; EPP_PROVIDER_TENANT_ID = $inputs.ProviderTenantId }
}
$deployment = @{
@@ -544,6 +545,8 @@ try {
Assert ($result.certificateAction -ceq $action -and $result.certificateReferenceResolved -eq $true) 'The persisted result must identify the action and resolved reference.'
$expectedLanguage = if ($inputs.Language -eq 'dotnet') { 'csharp' } else { 'javascript' }
Assert ($result.language -ceq $expectedLanguage -and $result.tenantRegionScope -ceq 'NA') 'Persist the customer-facing language identifier and detected tenant scope.'
+ Assert ($result.providerTenantId -ceq $inputs.ProviderTenantId -and
+ $null -eq $result.accessTokenScopeTenant) 'Basic-auth summaries must preserve the provider tenant without claiming an OAuth authority.'
Assert ($result.sourceRevision -ceq ('a' * 40) -and $result.source -ceq $inputs.SourceBaseUri -and
$result.packageFile -ceq $inputs.PackageFile -and $result.packageUrl -ceq $inputs.PackageUrl -and
$result.packageReleaseTag -ceq $inputs.PackageReleaseTag) 'The persisted result must identify the setup source and downloaded Function release.'
@@ -623,6 +626,38 @@ try {
Assert ($launcher.ParamBlock.Parameters.Name.VariablePath.UserPath -contains $parameter) "The launcher must forward $parameter."
}
Assert ($script:Answers.Count -eq 0) 'Every expected interactive answer must be consumed.'
+
+ $script:SetEndpointWithoutFederation = (Get-Command Set-EppApplicationEndpoint).ScriptBlock
+ function script:Set-EppApplicationEndpoint {
+ param($Inputs, $Context, $Outputs, $Certificate, $KeyId, [switch] $ConfigureFederation)
+ Assert $ConfigureFederation 'Telesign OAuth must request outbound managed-identity federation.'
+ & $script:SetEndpointWithoutFederation -Inputs $Inputs -Context $Context -Outputs $Outputs `
+ -Certificate $Certificate -KeyId $KeyId -ConfigureFederation:$false
+ }
+ $oauthInputs = @{} + $inputs
+ $oauthInputs.ProviderAuthentication = 'oauth'
+ $oauthInputs.AccessTokenScopeTenant = '99999999-9999-9999-9999-999999999999'
+ $oauthInputs.ProviderAllowedTenantIds = @($inputs.ProviderTenantId, $oauthInputs.AccessTokenScopeTenant)
+ $oauthProvider = @{} + $provider
+ $oauthProvider.Id = 'telesign'; $oauthProvider.DisplayName = 'Telesign'; $oauthProvider.AuthenticationMode = 'oauth'
+ $oauthProvider.AccessTokenScopeTenant = $oauthInputs.AccessTokenScopeTenant
+ $oauthProvider.Settings = @{} + $provider.Settings
+ $oauthProvider.Settings.EPP_PROVIDER_TENANT_ID = $oauthInputs.AccessTokenScopeTenant
+ $oauthProvider.Settings.EPP_PROVIDER_APP_ID = '88888888-8888-8888-8888-888888888888'
+ $oauthProvider.Settings.EPP_PROVIDER_SCOPE = 'api://88888888-8888-8888-8888-888888888888/.default'
+ $oauthContext = @{} + $context
+ $oauthContext.CertificatePlan = Get-EppCertificatePlan @planArgs @reuseArgs -NonInteractive
+ $oauthDeployment = @{} + $deployment
+ $oauthDeployment.Inputs = $oauthInputs; $oauthDeployment.ProviderConfiguration = $oauthProvider; $oauthDeployment.Context = $oauthContext
+ $result = Invoke-EppDeployment @oauthDeployment
+ Assert ($result.providerTenantId -ceq $inputs.ProviderTenantId -and
+ $result.accessTokenScopeTenant -ceq $oauthInputs.AccessTokenScopeTenant) 'OAuth summaries must retain distinct Security Store and access-token tenant IDs.'
+ $saved = @(Get-ChildItem -LiteralPath $Directory -Filter 'deployment-*.json' | ForEach-Object {
+ Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json
+ } | Where-Object providerAuthentication -eq 'oauth')
+ Assert ($saved.Count -eq 1 -and $saved[0].providerTenantId -ceq $result.providerTenantId -and
+ $saved[0].accessTokenScopeTenant -ceq $result.accessTokenScopeTenant -and
+ $saved[0].allowedProviderTenantIds -contains $result.accessTokenScopeTenant) 'Persist both tenant roles and the OAuth authority in the approved allowlist.'
}
finally {
if ($issued) { $issued.Certificate.Dispose() }
diff --git a/setup/tests/ProviderConfiguration.Tests.ps1 b/setup/tests/ProviderConfiguration.Tests.ps1
index f345c82..9672245 100644
--- a/setup/tests/ProviderConfiguration.Tests.ps1
+++ b/setup/tests/ProviderConfiguration.Tests.ps1
@@ -247,6 +247,17 @@ try {
Assert-Throws { Initialize-EppGraphAccess -Inputs $inputs -Context $context } 'provider configuration changed'
Assert ($script:Patches.Count -eq 0 -and $script:EnsureCalls -eq 0) 'Do not overwrite an allowlist added after approval, including on a newly created app.'
}
+ foreach ($tokenTenant in @($telesign.TenantId, 'ffffffff-ffff-ffff-ffff-ffffffffffff')) {
+ Reset-TestState
+ $script:Restriction = New-TestRestriction @($soprano.TenantId, $unknownTenant)
+ $inputs = New-TestInputs
+ $inputs.ProviderAuthentication = 'oauth'
+ $inputs.AccessTokenScopeTenant = $tokenTenant
+ Assert-Throws { Connect-EppContext -Inputs $inputs -Names @{ resourceGroup = 'unit' } -NonInteractive } '^provider-preflight-boundary$'
+ $expected = @($soprano.TenantId, $unknownTenant, $telesign.TenantId, $tokenTenant) | Sort-Object -Unique
+ Assert (($inputs.ProviderAllowedTenantIds -join ',') -ceq ($expected -join ',')) 'OAuth must allow both the Security Store and token tenants, deduplicate them, and preserve existing allowed tenants.'
+ Assert ($inputs.ProviderTenantId -ceq $telesign.TenantId) 'The token tenant must not replace the provider identity used for Security Store discovery.'
+ }
foreach ($alreadyConfigured in @($false, $true)) {
Reset-TestState
$script:Application.SignInAudience = 'AzureADMultipleOrgs'
diff --git a/setup/tests/Providers.Tests.ps1 b/setup/tests/Providers.Tests.ps1
index e22237f..4980c77 100644
--- a/setup/tests/Providers.Tests.ps1
+++ b/setup/tests/Providers.Tests.ps1
@@ -5,9 +5,10 @@ $setupDirectory = Split-Path -Parent $PSScriptRoot
$module = Import-Module (Join-Path $setupDirectory 'support' 'Epp.Setup.psm1') -Force -PassThru
$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
+ $assets = Join-Path $directory 'assets'
+ New-Item -ItemType Directory -Path (Join-Path $assets 'providers') -Force | Out-Null
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)
+ Copy-Item -LiteralPath (Join-Path $setupDirectory 'providers' $file) -Destination (Join-Path $assets 'providers' $file)
}
& $module {
param($SetupDirectory, $Directory)
@@ -24,6 +25,7 @@ try {
throw "Expected failure matching '$Pattern'."
}
$script:SetupDirectory = $SetupDirectory
+ $assets = Join-Path $Directory 'assets'
$script:Messages = [Collections.Generic.List[string]]::new()
$script:Prompts = [Collections.Generic.List[string]]::new()
$script:Answers = [Collections.Generic.Queue[string]]::new()
@@ -106,9 +108,23 @@ 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).' }
+ $profile = Read-EppJson (Join-Path $SetupDirectory 'providers' 'telesign.json')
+ Assert ($profile.deployment.authentication.mode -ceq 'oauth') 'Telesign deployments must select OAuth.'
+ $profile.deployment.AccessTokenScopeTenant = ''
+ Assert-Throws {
+ ConvertTo-EppProviderSettings -Profile $profile -Id telesign -DisplayName Telesign -EndpointRegion global -NonInteractive
+ } 'deployment.AccessTokenScopeTenant.*access-token tenant'
+ $profile.deployment.AccessTokenScopeTenant = '11111111-1111-1111-1111-111111111111'
+ foreach ($region in @('global', 'eu')) {
+ $profile.deployment.routes[$region].appId = '22222222-2222-2222-2222-222222222222'
+ $profile.deployment.routes[$region].scope = 'api://22222222-2222-2222-2222-222222222222/.default'
+ }
+ $profile | ConvertTo-Json -Depth 10 |
+ Set-Content -LiteralPath (Join-Path $assets 'providers' 'telesign.json') -Encoding utf8NoBOM
+
foreach ($id in @('telesign', 'soprano', 'infobip')) {
foreach ($region in @('global', 'eu')) {
- $configuration = Get-EppProvider -AssetDirectory $Directory `
+ $configuration = Get-EppProvider -AssetDirectory $assets `
-Provider $id -EndpointRegion $region -NonInteractive
$route = $configuration.Manifest.deployment.routes[$region]
$settings = $configuration.Settings
@@ -121,6 +137,12 @@ 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.'
+ Assert ($configuration.TenantId -ceq $configuration.Manifest.deployment.tenantId) 'Keep the Security Store tenant separate from token acquisition.'
+ $tokenTenant = if ($configuration.Manifest.deployment.Contains('AccessTokenScopeTenant')) {
+ $configuration.Manifest.deployment.AccessTokenScopeTenant
+ } else { $configuration.TenantId }
+ Assert ($configuration.AccessTokenScopeTenant -ceq $tokenTenant -and
+ $settings.EPP_PROVIDER_TENANT_ID -ceq $configuration.AccessTokenScopeTenant) 'Configure the Function OAuth authority from AccessTokenScopeTenant, retaining the tenantId default for older profiles.'
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.'
}
@@ -133,6 +155,9 @@ try {
$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.'
}
+ if ($id -eq 'telesign') {
+ Assert ($configuration.TenantId -cne $settings.EPP_PROVIDER_TENANT_ID) 'The token tenant must be independently configurable.'
+ }
}
}
Assert ($script:Prompts.Count -eq 0) 'Complete provider inputs must not prompt for a channel.'
@@ -146,6 +171,23 @@ try {
Assert-Throws { ConvertTo-EppProviderSettings -Profile $profile -Id soprano -DisplayName Soprano -NonInteractive } '\-EndpointRegion is required'
Assert-Throws { ConvertTo-EppProviderSettings -Profile $profile -Id soprano -DisplayName Soprano -EndpointRegion invalid -NonInteractive } 'Unknown EndpointRegion'
+ $profile.deployment.Remove('AccessTokenScopeTenant')
+ $configuration = ConvertTo-EppProviderSettings -Profile $profile -Id soprano -DisplayName Soprano -EndpointRegion eu -NonInteractive
+ Assert ($configuration.Settings.EPP_PROVIDER_TENANT_ID -ceq $profile.deployment.tenantId) 'Legacy profiles without AccessTokenScopeTenant must keep their original OAuth authority.'
+ foreach ($value in @($null, '', ' ', 'invalid', '', [Guid]::Empty.ToString())) {
+ $profile.deployment.AccessTokenScopeTenant = $value
+ Assert-Throws {
+ ConvertTo-EppProviderSettings -Profile $profile -Id soprano -DisplayName Soprano -EndpointRegion eu -NonInteractive
+ } 'deployment.AccessTokenScopeTenant'
+ }
+ $profile.deployment.AccessTokenScopeTenant = ' AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA '
+ $configuration = ConvertTo-EppProviderSettings -Profile $profile -Id soprano -DisplayName Soprano -EndpointRegion eu -NonInteractive
+ Assert ($configuration.Settings.EPP_PROVIDER_TENANT_ID -ceq 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') 'Normalize the token tenant independently of the Security Store tenant.'
+ $profile.deployment.tenantId = 'invalid'
+ Assert-Throws {
+ ConvertTo-EppProviderSettings -Profile $profile -Id soprano -DisplayName Soprano -EndpointRegion eu -NonInteractive
+ } 'deployment.tenantId'
+
foreach ($region in @('global', 'eu')) {
$profile = Read-EppJson (Join-Path $SetupDirectory 'providers' 'soprano.json')
$profile.deployment.routes.Remove($region)
@@ -217,7 +259,7 @@ try {
SubscriptionId = '22222222-2222-2222-2222-222222222222'
ApplicationId = '33333333-3333-3333-3333-333333333333'
Location = 'westus2'; ResourcePrefix = 'unit'; Language = 'javascript'; ServicePlan = 'FC1'
- AssetDirectory = $SetupDirectory
+ AssetDirectory = $assets
SourceBaseUri = "https://raw.githubusercontent.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/$('a' * 40)/setup"
}
foreach ($id in @('telesign', 'soprano', 'infobip')) {
@@ -226,6 +268,13 @@ try {
Assert-Throws {
Invoke-EppSetup @inputs -Provider $id -EndpointRegion $region -OutputDirectory $Directory -NonInteractive
} '^preflight-reached$'
+ $profile = Read-EppJson (Join-Path $assets 'providers' "$id.json")
+ $tokenTenant = if ($profile.deployment.Contains('AccessTokenScopeTenant')) {
+ $profile.deployment.AccessTokenScopeTenant
+ } else { $profile.deployment.tenantId }
+ Assert ($script:PreflightInputs.ProviderTenantId -ceq $profile.deployment.tenantId -and
+ $script:PreflightInputs.AccessTokenScopeTenant -ceq $tokenTenant -and
+ $script:PreflightInputs.ProviderAuthentication -ceq 'oauth') 'Preflight must receive both tenant identities and the chosen authentication mode.'
}
}
Assert ($script:Prompts.Count -eq 0) 'Unattended setup must reach preflight without any channel input.'
@@ -279,7 +328,7 @@ try {
Assert ($script:Answers.Count -eq 0) 'Consume every expected input without adding language or save-output prompts.'
$names = Get-EppResourceNames -SubscriptionId $inputs.SubscriptionId -ApplicationId $inputs.ApplicationId -ResourcePrefix $inputs.ResourcePrefix
- $configuration = Get-EppProvider -AssetDirectory $Directory -Provider telesign -EndpointRegion global -NonInteractive
+ $configuration = Get-EppProvider -AssetDirectory $assets -Provider telesign -EndpointRegion global -NonInteractive
$script:Messages.Clear()
Show-EppDeploymentResult -Inputs $inputs -Names $names -ProviderConfiguration $configuration `
-EndpointUrl 'https://unit.azurewebsites.net/api/SendOtp' -ResultPath (Join-Path $Directory 'deployment.json')
@@ -291,6 +340,8 @@ try {
Assert ($result.Contains('did not change the External Phone Provider policy')) 'Setup must not claim policy activation.'
Assert ($script:Highlights -contains 'IMPORTANT: Save this output. The endpoint URL and Application (client) ID are required for policy updates.') 'Highlight the save-output reminder in yellow.'
Assert ($result.Contains('docs/GETTING-STARTED.md#4-connect-your-provider-and-test')) 'Link the existing provider, policy, and testing guidance from the completion output.'
+ Assert ($result.Contains("access-token tenant '$($configuration.AccessTokenScopeTenant)'") -and
+ $result.Contains($configuration.Settings.EPP_PROVIDER_SCOPE)) 'OAuth handoff must name the target tenant and scope.'
} $setupDirectory $directory
Write-Host 'Provider profiles, channel-independent setup, prompts, and policy handoff checks passed.'
}
diff --git a/setup/tests/Selections.Tests.ps1 b/setup/tests/Selections.Tests.ps1
index 82f843b..994d0e9 100644
--- a/setup/tests/Selections.Tests.ps1
+++ b/setup/tests/Selections.Tests.ps1
@@ -410,7 +410,7 @@ try {
}
$setup = @{
TenantId = $script:Tenant; SubscriptionId = $script:Subscription
- Provider = 'telesign'; EndpointRegion = 'global'; Language = 'javascript'; ServicePlan = 'FC1'
+ Provider = 'soprano'; EndpointRegion = 'global'; Language = 'javascript'; ServicePlan = 'FC1'
ResourcePrefix = 'unit'; OutputDirectory = $Directory
AssetDirectory = $ModulePath
SourceBaseUri = "https://raw.githubusercontent.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/$('a' * 40)/setup"
diff --git a/setup/tests/ServicePlans.Tests.ps1 b/setup/tests/ServicePlans.Tests.ps1
index db1870d..cce378d 100644
--- a/setup/tests/ServicePlans.Tests.ps1
+++ b/setup/tests/ServicePlans.Tests.ps1
@@ -93,7 +93,7 @@ try {
ApplicationId = '22222222-2222-2222-2222-222222222222'
SubscriptionId = '33333333-3333-3333-3333-333333333333'
Location = 'westus2'; ResourcePrefix = 'unit'; Platform = 'Node.js'; Language = 'javascript'
- ProviderAuthentication = 'apiKey'; ServicePlan = 'FC1'; BuildStrategy = 'ready'
+ ProviderAuthentication = 'oauth'; ServicePlan = 'FC1'; BuildStrategy = 'ready'
PackageFile = 'epp-javascript.zip'
PackageReleaseTag = 'epp-packages-1-1'
PackageUrl = 'https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-1-1/epp-javascript.zip'
@@ -103,14 +103,18 @@ try {
}
$names = Get-EppResourceNames -SubscriptionId $inputs.SubscriptionId -ApplicationId $inputs.ApplicationId -ResourcePrefix unit
$provider = @{
- DisplayName = 'Telesign'; EndpointRegion = 'global'; AuthenticationMode = 'apiKey'
+ DisplayName = 'Telesign'; EndpointRegion = 'global'; AuthenticationMode = 'oauth'
+ TenantId = '44444444-4444-4444-4444-444444444444'
+ AccessTokenScopeTenant = '77777777-7777-7777-7777-777777777777'
Settings = @{
- EPP_PROVIDER_TENANT_ID = '44444444-4444-4444-4444-444444444444'
+ EPP_PROVIDER_TENANT_ID = '77777777-7777-7777-7777-777777777777'
+ EPP_PROVIDER_APP_ID = '88888888-8888-8888-8888-888888888888'
+ EPP_PROVIDER_SCOPE = 'api://88888888-8888-8888-8888-888888888888/.default'
EPP_PROVIDER_SMS_ENDPOINT = 'https://sms.provider.contoso.com/send'
EPP_PROVIDER_VOICE_ENDPOINT = 'https://voice.provider.contoso.com/send'
EPP_PROVIDER_TIMEOUT_MS = '1500'; EPP_PROVIDER_RETRY_INTERVAL_MS = '100'
}
- Manifest = @{ deployment = @{ authentication = @{ keyVaultSecretName = 'key'; identityKeyVaultSecretName = 'id' } } }
+ Manifest = @{ deployment = @{ authentication = @{ mode = 'oauth' } } }
}
$context = @{
Application = @{ SignInAudience = 'AzureADMultipleOrgs' }
@@ -153,6 +157,13 @@ try {
}
else { Assert ($script:Calls[0] -like '*geoRegions*') 'EP1 must keep its ARM availability check.' }
}
+ $script:Messages.Clear()
+ Show-EppPlan -Inputs $inputs -Names $names -ProviderConfiguration $provider -Context $context
+ $plan = $script:Messages -join "`n"
+ Assert ($plan.Contains("Security Store tenant: $($provider.TenantId)") -and
+ $plan.Contains("Access-token tenant: $($provider.AccessTokenScopeTenant)")) 'Approval must disclose the two distinct provider tenants.'
+ Assert ($plan.Contains('Telesign OAuth: add a federated credential') -and
+ -not $plan.Contains('Soprano OAuth')) 'Federation guidance must identify the selected OAuth provider.'
$inputs.ServicePlan = 'FC1'
$script:Regions = '[{"name":"East US"}]'
Assert-Throws { Assert-EppServicePlanLocation -Inputs $inputs } 'FC1 is unavailable'
@@ -280,9 +291,11 @@ try {
} 'stop-after-parameters'
Assert ($script:Activities -contains 'Deploying Azure resources') 'ARM deployment must use the shared activity indicator.'
Assert ($script:DeploymentParameters.servicePlan.value -ceq $id) 'The approved plan must be passed to Bicep.'
- foreach ($setting in @('EPP_PROVIDER_SMS_ENDPOINT', 'EPP_PROVIDER_VOICE_ENDPOINT')) {
- Assert ($script:DeploymentParameters.providerSettings.value[$setting] -ceq $provider.Settings[$setting]) 'Both approved endpoints must be passed to Bicep.'
+ foreach ($setting in @('EPP_PROVIDER_SMS_ENDPOINT', 'EPP_PROVIDER_VOICE_ENDPOINT', 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_SCOPE')) {
+ Assert ($script:DeploymentParameters.providerSettings.value[$setting] -ceq $provider.Settings[$setting]) 'Approved endpoints and OAuth settings must be passed to Bicep.'
}
+ Assert ($script:DeploymentParameters.providerSettings.value.EPP_PROVIDER_TENANT_ID -ceq $provider.AccessTokenScopeTenant -and
+ $script:DeploymentParameters.providerSettings.value.EPP_PROVIDER_TENANT_ID -cne $provider.TenantId) 'Deploy the access-token authority rather than the Security Store tenant.'
Assert (-not $script:DeploymentParameters.providerSettings.value.ContainsKey('EPP_PROVIDER_CHANNEL')) 'Bicep must not receive a channel restriction.'
}