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 ![Key Vault Objects menu with Secrets selected and the Generate/Import action visible](images/onboarding/key-vault-secrets.png) -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**. ![Unsaved Create a secret form showing a sample name, masked placeholder value, and Enabled set to Yes](images/onboarding/key-vault-create-secret.png) *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.' }