From 46b2cc07afdeb963f6ee87c496c470651617114e Mon Sep 17 00:00:00 2001 From: James Xian Date: Tue, 15 Sep 2026 21:11:11 -0700 Subject: [PATCH] Add guided EPP endpoint setup Add a single customer entry point for EPP Step 2 deployment with guided prerequisite installation, tenant-specific Azure and Graph sign-in, commit-pinned assets, provider/language/channel/region selection, deterministic resource naming, verified package publication, and fail-closed Azure/Graph validation. After one deployment approval, configure the dedicated third-party app, provider-tenant allowed-audience restriction, Epp.Invoke application role, endpoint and Microsoft phone-provider service principals, caller role assignment, Microsoft Graph Application.Read.All grant, encryption certificate/private-key readback, Easy Auth caller restriction, and Soprano OAuth federation. PowerShell 7 and Azure CLI remain external prerequisites. Missing Graph modules and the Azure CLI Bicep component can be installed after separate consent or with -InstallPrerequisites. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 16 + README.md | 43 +- docs/CONTRACT.md | 49 +- docs/ONBOARDING.md | 110 +- dotnet/README.md | 5 +- dotnet/Src/AppConfig.cs | 12 + dotnet/Src/DispatchEngine.cs | 65 +- dotnet/Src/Models.cs | 2 +- dotnet/Src/Providers/SopranoProvider.cs | 7 +- dotnet/Src/Providers/TelesignProvider.cs | 2 +- dotnet/tests/ContractTests.cs | 17 +- dotnet/tests/EngineTests.cs | 27 +- javascript/README.md | 10 +- javascript/src/functions/config.js | 6 + javascript/src/functions/dispatch.js | 57 +- javascript/src/functions/providers/soprano.js | 13 +- .../src/functions/providers/telesign.js | 2 +- javascript/test/dispatch.test.js | 46 +- javascript/test/sendotp.test.js | 20 +- python/README.md | 5 +- python/src/config.py | 14 + python/src/dispatch.py | 57 +- python/src/models.py | 2 + python/src/providers/soprano.py | 11 +- python/src/providers/telesign.py | 2 +- python/tests/test_contract.py | 17 +- python/tests/test_engine.py | 34 +- python/tests/test_function_app.py | 4 +- setup/.gitignore | 7 + setup/EPP-Setup.psd1 | 14 + setup/Setup-Epp.ps1 | 87 + setup/docs/README.md | 231 +++ setup/docs/Troubleshooting.md | 180 +++ setup/infra/main.bicep | 55 + setup/infra/resources.bicep | 335 ++++ setup/packages/catalog.json | 26 + setup/providers/catalog.json | 15 + setup/providers/soprano.json | 45 + setup/providers/telesign.json | 44 + setup/support/Epp.Packages.ps1 | 156 ++ setup/support/Epp.Setup.psm1 | 1418 +++++++++++++++++ 41 files changed, 3039 insertions(+), 229 deletions(-) create mode 100644 setup/.gitignore create mode 100644 setup/EPP-Setup.psd1 create mode 100644 setup/Setup-Epp.ps1 create mode 100644 setup/docs/README.md create mode 100644 setup/docs/Troubleshooting.md create mode 100644 setup/infra/main.bicep create mode 100644 setup/infra/resources.bicep create mode 100644 setup/packages/catalog.json create mode 100644 setup/providers/catalog.json create mode 100644 setup/providers/soprano.json create mode 100644 setup/providers/telesign.json create mode 100644 setup/support/Epp.Packages.ps1 create mode 100644 setup/support/Epp.Setup.psm1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b6f0a9..35a290f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,22 @@ on: pull_request: jobs: + epp-setup: + name: EPP setup (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Compile Bicep without deploying + shell: pwsh + run: | + az bicep install + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + az bicep build --file (Join-Path $env:GITHUB_WORKSPACE 'setup/infra/main.bicep') --outfile (Join-Path $env:RUNNER_TEMP 'epp-main.json') + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + javascript: name: JavaScript (Node.js) runs-on: ubuntu-latest diff --git a/README.md b/README.md index 068052f..3ab59de 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,25 @@ by default. Deploy each language separately, not all three to the same Function New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** for setup, config, running, securing, and deploying, step by step. +## Guided EPP setup + +Use **[setup](setup/docs/README.md)** for **Step 2: endpoint deployment**. Download only +`Setup-Epp.ps1`; it downloads its supporting PowerShell, Bicep, package catalog, and provider JSON +from the same commit. Customers select a language, provider, SMS or voice, Global or EU endpoint, +and a resource prefix, then approve one complete plan. Manual Step 1 only creates the dedicated app +registration; PowerShell configures its service principals, `Epp.Invoke`, Microsoft caller access, +Graph `Application.Read.All`, the provider-tenant allowlist preview, encryption certificate, and +Easy Auth. The home tenant remains allowed by Entra. Policy activation remains manual. + ## Download a Function ZIP Download the preview ZIP for your chosen language: | Language | Download | Contents | |---|---|---| -| JavaScript | [epp-javascript.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-javascript.zip) | Application and production dependencies | -| .NET | [epp-dotnet-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-dotnet-source-preview-20260915/epp-dotnet-source.zip) | C# Function source and project file; build/publish before deployment | -| Python | [epp-python-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-python-source.zip) | Source for Azure remote build on Linux | +| JavaScript | [epp-javascript.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-javascript.zip) | Application and production dependencies | +| .NET | [epp-dotnet-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-dotnet-source.zip) | C# Function source and project file; build/publish before deployment | +| Python | [epp-python-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-python-source.zip) | Source for Azure remote build on Linux | Customers do not need PowerShell or a local build toolchain to download these files. Verify downloads against the corresponding release's `SHA256SUMS.txt`. Configure the target Function App's runtime, app settings, @@ -141,7 +151,12 @@ how code accesses configuration, not the environment-variable names. | `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. | | `EPP_ENCRYPTION_KEY_ID` | Optional | Expected encryption key ID; mismatch only produces an advisory warning. | | `EPP_PROVIDER_NAME` | Live delivery | Selected adapter's manifest ID. No default provider. | -| `EPP_PROVIDER_ENDPOINT` | Live delivery | HTTPS **base URL**, in the same environment as the provider credentials; the adapter adds its route. | +| `EPP_PROVIDER_ENDPOINT` | Live delivery | Complete provider-approved HTTPS request URL selected from the provider profile. | +| `EPP_PROVIDER_CHANNEL` | Guided deployment | Selected `sms` or `voice` route; other live-request channels fail closed. | +| `EPP_PROVIDER_ENDPOINT_REGION` | Guided deployment metadata | Selected `global` or `eu` route label. | +| `EPP_PROVIDER_AUTH_MODE` | Live delivery | Must match the adapter: `apiKey` for Telesign or `oauth` for Soprano. | +| `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE` | Soprano OAuth | Provider tenant and selected API scope. | +| `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | Soprano OAuth | Existing multitenant application and outbound user-assigned managed identity used for client-assertion exchange. | | `EPP_PROVIDER_TIMEOUT_MS` | Optional | Decimal milliseconds. Defaults to `1500`, capped at `2500`; not an end-to-end deadline. | | `EPP_PROVIDER_ACCOUNT_NAME` | Adapter-dependent | Sender/account metadata, not an API key or credential identity. | | `KEY_VAULT_URL` | Provider credential lookup | URI of the vault containing the manifest-named provider secrets. Separate from the encryption-key reference. | @@ -153,11 +168,9 @@ how code accesses configuration, not the environment-variable names. 2. **In Azure:** set the same application variables on the selected Function App (or serving slot) under **Settings → Environment variables → App settings**, then apply the changes. Local settings are not published automatically. Configure host storage separately for the selected hosting plan. -3. Store provider API keys and any required identity secrets in Key Vault using the **exact names in - the adapter manifest**. Grant that app/slot's managed identity *Key Vault Secrets User* on those - secrets. An API key in a local environment variable is not a supported replacement for the resolver. - See the [provider credential naming table](docs/ONBOARDING.md#provider-credential-names) and - [local use of existing cloud secrets](docs/ONBOARDING.md#local-settings-and-cloud-secrets). +3. For Telesign, store provider API credentials in Key Vault using the exact manifest names. For + Soprano, configure provider consent plus the profile's tenant/scope and outbound managed-identity + federation; the Function stores no Soprano client secret. Evaluation requests do not need provider variables or provider secrets. They still need the decryption key. The default credential resolvers use `ManagedIdentityCredential`, **not** the developer's CLI @@ -170,15 +183,13 @@ or base64 PEM directly; use a reference such as `@Microsoft.KeyVault(SecretUri=h for `EPP_DECRYPTION_KEY_PEM` in Azure app settings, where the platform resolves it. Configure inbound issuer/audience/caller trust in **Easy Auth**, not these application variables. -Incoming `tenantId`, `channel`, `mode` and `ttlSeconds` are request data. No outbound OAuth settings -are supported by this main-based implementation. +Incoming `tenantId`, `channel`, `mode` and `ttlSeconds` are request data and never override the +configured provider route or authentication. ## Telesign EPP -The `telesign` adapter uses `POST https://verify.telesign.com/integration/msft/cyot` -for both SMS and Voice. Set `EPP_PROVIDER_NAME=telesign` and -`EPP_PROVIDER_ENDPOINT=https://verify.telesign.com` (the base URL, without the route). -This replaces the legacy `/v1/messaging` and `/v1/voice` integrations in all three languages. +The `telesign` adapter sends its JSON contract to the complete SMS or voice URL selected from the +provider profile. It does not append or infer a route. Basic authentication uses `base64(customer-id:api-key)`, with the existing Key Vault secrets `telesign-customer-id` and `telesign-api-key`. Digest and Phase 2 token authentication are not @@ -212,7 +223,7 @@ lookup entirely, rather than invoking Telesign shutter mode. Responses normalize `reference_id` and `status.code`/`status.description` internally; provider metadata is not logged or exposed in the public nonce response. Existing numeric success codes -are retained (SMS: 200, 203, 290-292; Voice: 100-103). CYOT code `3001` ("Message in progress"), +are retained (SMS: 200, 203, 290-292; Voice: 100-103). EPP code `3001` ("Message in progress"), observed for both channels, is also accepted on successful HTTP responses. This acknowledges provider acceptance, not handset receipt or completed audio playback. The supplied EPP integration overview does not provide a complete replacement status-code catalog. Missing, malformed, or diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 7c916e6..6f63115 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -99,11 +99,11 @@ nonblank strings. Supply the password explicitly to preserve leading zeros; it i from `message`. These values are forwarded unchanged as `voice.text2voice`, without a top-level `text` field. Missing or invalid speech returns `400` before credential lookup or provider HTTP. SMS continues to use `message`, and evaluation continues to skip provider-specific validation and I/O. -Soprano authentication remains API-key-only (`X-MEMS-API-ID` and `X-MEMS-API-Key`, resolved from -`soprano-api-id` and `soprano-api-key` in Key Vault). No provider JWT, OAuth flow, token endpoint, -or bearer-token forwarding is added. Existing platform caller authentication is unchanged. +Soprano uses OAuth client-assertion exchange. The outbound user-assigned managed identity obtains an +`api://AzureADTokenExchange/.default` assertion for the existing multitenant application, which then +requests the configured provider scope. Existing platform caller authentication is unchanged. -Use a speech language supported by the selected Soprano endpoint and account. On QA4, an API-key +Use a speech language supported by the selected Soprano endpoint and account. On QA4, an OAuth voice request using `en` returned HTTP `400` with error code `400101`; the same request structure using `en-US` returned HTTP `201` with `ENROUTE` on September 15, 2026. This confirms acceptance, not handset receipt or audio quality. The adapter preserves the supplied language and does not @@ -183,8 +183,9 @@ success-looking status. Explicit `Block`/`StepUp` outcomes remain non-success re Each provider is one unit exposing three things: - **`manifest`**: protocol facts only: - - `id`: provider id selected by `EPP_PROVIDER_NAME`; its base URL is `EPP_PROVIDER_ENDPOINT` - - `auth`: `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }`; other modes fail closed + - `id`: provider id selected by `EPP_PROVIDER_NAME`; its complete request URL is `EPP_PROVIDER_ENDPOINT` + - `auth`: either `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }` or + `{ mode: 'oauth' }`; unsupported modes fail closed - `responseMapping`: map of provider status → `Continue` | `Fail` | `Block` | `StepUp` (+ `default`) - **`buildRequest({ channel, endpoint, dispatch, credential, env })`** → `{ url, method, headers, body }` - **`parseResponse({ httpStatus, ok, json })`** → `ParsedResponse`, containing `success`, @@ -205,19 +206,17 @@ is parsed once and normalized inside its adapter. No serialization framework or class hierarchy is required. Adapters require registration in the chosen runtime. Consult the selected adapter and its manifest -for required credentials and options: the manifest declares secret names and protocol mappings; +for required credentials and options: the manifest declares authentication and protocol mappings; the implementation reads adapter-specific options from app settings. Individual API contracts remain in the adapters; the [onboarding credential naming table](ONBOARDING.md#provider-credential-names) lists the exact manifest secret names for provisioning and authorized local tests. Keep that table aligned with the manifests; never include secret values in documentation or the settings sample. -### Telesign CYOT integration +### Telesign EPP integration -SMS and Voice both use `POST https://verify.telesign.com/integration/msft/cyot` with JSON. Configure -the base URL as `https://verify.telesign.com`. The adapter supplies `recipient.phone_number`, the -unchanged `message.text`, optional `message.language`, one selected `channels[].channel`, and -`correlation_id`. Keep the leading `+` in the E.164 phone number; the guide's example `12345678` -does not satisfy its own required phone-number pattern. +SMS and Voice use the complete provider-approved URLs selected from the provider profile. The adapter +supplies `recipient.phone_number`, the unchanged `message.text`, optional `message.language`, one +selected `channels[].channel`, and `correlation_id`. Keep the leading `+` in the E.164 phone number. Phase 1 supports Basic and Digest; this sample implements Basic only. Per [Telesign's authentication instructions](https://developer.telesign.com/enterprise/docs/authentication#basic-authentication), @@ -246,18 +245,23 @@ Set by provisioning. **Identical names across all languages.** | Key | Purpose | |-----|---------| | `EPP_PROVIDER_NAME` | registered id of the selected provider; `` is a placeholder, not a bundled default | -| `EPP_PROVIDER_ENDPOINT` | absolute HTTPS base URL with a hostname, port 1–65535, and no userinfo or fragment; the final adapter URL is also validated; redirects are not followed | +| `EPP_PROVIDER_ENDPOINT` | complete absolute HTTPS request URL for the selected channel/region, with a hostname, port 1–65535, and no userinfo or fragment; redirects are not followed | +| `EPP_PROVIDER_CHANNEL` | optional configured `sms` or `voice` route; when set, other live-request channels fail closed | +| `EPP_PROVIDER_ENDPOINT_REGION` | selected `global` or `eu` route label; informational at runtime | +| `EPP_PROVIDER_AUTH_MODE` | must match the selected adapter (`apiKey` for Telesign, `oauth` for Soprano) | +| `EPP_PROVIDER_TENANT_ID` | selected provider tenant; added to the Step 1 app's allowed-tenants preview and used as the OAuth authority for Soprano | +| `EPP_PROVIDER_SCOPE` | Soprano OAuth scope | +| `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | client application and user-assigned identity used for Soprano client-assertion exchange | | `EPP_PROVIDER_ACCOUNT_NAME` | sender/source only when required by the selected adapter | | `EPP_PROVIDER_TIMEOUT_MS` | trimmed ASCII decimal milliseconds; default 1500 for missing/invalid/nonpositive values; capped at 2500. Not a whole-invocation deadline | | `EPP_DECRYPTION_KEY_PEM` | single RSA private key for JWE decryption, PEM or base64-encoded PEM; use a Key Vault secret reference in Azure, not a plaintext private key in shared settings | | `EPP_ENCRYPTION_KEY_ID` | optional expected JWE `kid`; after successful decryption, a mismatch emits only `encryption_key_id_mismatch`. Advisory, not a key selector or authentication check | -| `KEY_VAULT_URL` | Key Vault URI (provider API keys) | +| `KEY_VAULT_URL` | Key Vault URI for API-key providers | | `AZURE_CLIENT_ID` | set for a user-assigned managed identity | -Provider credential values live in **Key Vault**, under the names in the selected adapter's manifest, -and are fetched via **managed identity** with the *Key Vault Secrets User* role. Do not put credential -values in code or app settings. No additional customer-private configuration or new environment -variable is needed for this guidance. +Telesign credentials live in **Key Vault**, under the names in its manifest, and are fetched via +managed identity. Soprano exchanges an outbound managed-identity assertion for a token in the +configured provider tenant/scope. Do not put provider secrets in code or app settings. Caller trust is configured in **Easy Auth**, not application environment variables: pin the trusted tenant issuer, the endpoint-app audience and the authorized SAS caller application ID. Incoming @@ -266,9 +270,10 @@ guard or backup token validation. See [platform onboarding](ONBOARDING.md#2-prov ### Default provider and configuration readers -Provision `EPP_PROVIDER_NAME` with the customer's selected provider, plus that account's -`EPP_PROVIDER_ENDPOINT` and Key Vault credentials. A missing or unknown provider fails closed; -there is no implicit default or automatic failover. Request-body provider fields are not used. +Provision `EPP_PROVIDER_NAME` with the customer's selected provider, plus the complete selected +channel/region `EPP_PROVIDER_ENDPOINT` and matching authentication settings. A missing or unknown +provider fails closed; there is no implicit default or automatic failover. Request-body provider +fields are not used. The shared configuration readers are [JavaScript `readConfig`](../javascript/src/functions/config.js), [Python `read_config`](../python/src/config.py), and [.NET `AppConfig.Read`](../dotnet/Src/AppConfig.cs). diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index a7a3ce0..a2fd93a 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -13,69 +13,53 @@ Use [CONTRACT.md](CONTRACT.md) for the full request contract and production limi 2. **Run the app setup script.** - - **The script, command, and prerequisites will be provided later.** Run it using the supplied - instructions, verify it succeeded, and retain the app/resource IDs and configuration outputs. - Do not assume it creates provider secrets or deploys the Function code. - -3. **Fill in app settings.** - - - Reuse your provider's existing cloud API key and matching ID. Store the raw values in Key Vault - under these exact names, shared across all three languages: - - | Provider | API credential secret | Matching identity secret | Authentication | - |---|---|---|---| - | `telesign` | `telesign-api-key` | `telesign-customer-id` | Basic: base64 of `customer-id:api-key` | - | `soprano` | `soprano-api-key` | `soprano-api-id` | `X-MEMS-API-Key` and `X-MEMS-API-ID` | - | `infobip` | `infobip-api-key` | None | `Authorization: App ` | - | `sinch` | `sinch-api-token` | None | `Authorization: Bearer ` | - - Secret names use lowercase and hyphens. Keep `sinch-api-token` unchanged. Store the API key and - matching ID separately, not a prebuilt Authorization header, base64 credential pair, or Entra token. - The adapter builds the headers; there is no provider OAuth/JWT acquisition flow. - - Enable the Function's managed identity and grant it **Key Vault Secrets User** access to the - required secrets. Confirm vault network access and that the keys match the provider environment. - Select one registered provider per deployment; there is no default. Unsupported providers need - an adapter first; see [adding a provider](../README.md#contributing-a-language-or-provider). - - - Start with [local.settings.sample.json](local.settings.sample.json) beside the chosen app's - `host.json`. Replace placeholders in `Values`; all values must be strings. For Telesign, use: - - ```json - { - "EPP_PROVIDER_NAME": "telesign", - "EPP_PROVIDER_ENDPOINT": "https://verify.telesign.com", - "KEY_VAULT_URL": "https://.vault.azure.net/" - } - ``` - - These are entries in `Values`, not a complete settings file. Use the adapter's **base URL**; - it adds the send path. App-setting names use uppercase and underscores. Provider API keys stay - in Key Vault, not `Values`: `TELESIGN_API_KEY`, `SOPRANO_API_KEY`, and `EPP_PROVIDER_API_KEY` - are not read by the production resolvers. `EPP_PROVIDER_ACCOUNT_NAME` is optional sender metadata, - not an API/customer ID. See the [settings catalog](CONTRACT.md#4-configuration-app-settings--env) - for adapter options and `AZURE_CLIENT_ID` when using a user-assigned managed identity. - - Set `FUNCTIONS_WORKER_RUNTIME` to `node`, `python`, or `dotnet-isolated`. Local - `UseDevelopmentStorage=true` requires Azurite; configure Azure host storage separately. - Use a local test private key for `EPP_DECRYPTION_KEY_PEM`; in Azure, use a Key Vault reference - and give the caller the matching public key. Core Tools does not resolve Key Vault references - locally. `EPP_ENCRYPTION_KEY_ID` is advisory only; this sample has one decryption key, not - multi-key rotation. The decryption key, provider credentials, and caller authentication are separate. - - For local work, keep the host **loopback-only**, without tunnels or public forwarding. Core Tools - has no Easy Auth, and `ManagedIdentityCredential` cannot use your CLI login. `AZURE_CLIENT_ID` - does not create a local identity. Use offline tests or evaluation mode by default. An authorized - live test can inject a private resolver that reads the same cloud secrets into memory using a - signed-in identity with secret-read permission. Do not add a production credential fallback, - print secrets, persist a secret cache, or change cloud settings merely to test locally. - - `KEY_VAULT_URL` selects the provider credential vault independently of the decryption-key reference. - Timeout defaults to 1500 ms and caps at 2500 ms; zero does not disable it. Retry settings are unused. - Configure caller trust in Easy Auth, not legacy `EPP_EXPECTED_*` or `EPP_TENANT_ID` settings. + + Use the [guided EPP setup](../setup/docs/README.md) after manually creating only the dedicated + endpoint application registration. Download only `setup/Setup-Epp.ps1`; it retrieves commit-pinned support scripts, + Bicep, provider profiles, and the selected language package. Choose a provider, SMS or voice, + Global or EU, and a resource prefix, then approve one complete deployment plan. + + After approval, the script configures the app registration and enterprise application, creates + the Microsoft phone-provider service principal, assigns `Epp.Invoke`, grants it Microsoft Graph + `Application.Read.All`, and restricts the multi-tenant app through the Entra allowed-tenants + preview to its home tenant plus the selected provider tenant. It then deploys the Function and + configures Easy Auth. It does not purchase the provider offer, grant provider API consent/roles, + or activate the EPP policy. + +3. **Complete provider authentication and settings.** + + + The setup script writes the selected provider route and authentication settings: + + | Provider | API credential secret | Matching identity secret | Authentication | + |---|---|---|---| + | `telesign` | `telesign-api-key` | `telesign-customer-id` | Basic: base64 of `customer-id:api-key` | + | `soprano` | None | None | OAuth client assertion using the outbound user-assigned managed identity | + | `infobip` | `infobip-api-key` | None | `Authorization: App ` | + | `sinch` | `sinch-api-token` | None | Static token authentication | + + For Telesign, store the raw key and customer ID separately and grant the Function identity + **Key Vault Secrets User** access. For Soprano, complete provider consent/application-role + onboarding for the existing multitenant application. The setup creates the disclosed federated + identity credential; it does not grant access to Soprano's API. + + + Start with [local.settings.sample.json](local.settings.sample.json) beside the chosen app's + `host.json`. Replace placeholders in `Values`; all values must be strings. `EPP_PROVIDER_ENDPOINT` + is the complete provider-approved request URL selected for the channel and Global/EU region. + `EPP_PROVIDER_AUTH_MODE` must match the adapter: `apiKey` for Telesign or `oauth` for Soprano. + Provider API keys stay in Key Vault, not `Values`. + + Set `FUNCTIONS_WORKER_RUNTIME` to `node`, `python`, or `dotnet-isolated`. Local + `UseDevelopmentStorage=true` requires Azurite; configure Azure host storage separately. + Use a local test private key for `EPP_DECRYPTION_KEY_PEM`; in Azure, use a Key Vault reference + and give the caller the matching public key. Core Tools does not resolve Key Vault references + locally. `EPP_ENCRYPTION_KEY_ID` is advisory only; this sample has one decryption key, not + multi-key rotation. The decryption key, provider credentials, and caller authentication are separate. + + For local work, keep the host **loopback-only**, without tunnels or public forwarding. Core Tools + has no Easy Auth, and managed identity cannot use your CLI login. Use offline tests or evaluation + mode by default; do not add a production credential fallback merely to test locally. 4. **Deploy the Functions.** diff --git a/dotnet/README.md b/dotnet/README.md index c14c42c..f58fadb 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -45,7 +45,8 @@ For local evaluation, start Azurite and replace the test-key placeholder in this } ``` -For live delivery, add `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and `KEY_VAULT_URL` to `Values`. +For live delivery, add `EPP_PROVIDER_NAME`, the complete selected `EPP_PROVIDER_ENDPOINT`, and the +matching provider authentication settings to `Values`. Add `EPP_PROVIDER_ACCOUNT_NAME` and adapter-specific options only when required. Optional `EPP_PROVIDER_TIMEOUT_MS` is a string such as `"1500"`. Replace placeholders; store provider credentials under the adapter manifest's Key Vault secret names, not in local settings. See the @@ -74,7 +75,7 @@ authenticate SAS: anyone with the public key can encrypt a request, and a fixed Use incoming `mode: 2` or `mode: "evaluation"` as the generic shutter for every provider: platform authentication on Azure, handler validation and decryption run, but provider lookup, provider Key Vault reads and provider HTTP do not. No provider configuration or diagnostic environment flag is required. -Live requests forward the rendered message unchanged using the configured provider's API key and +Live requests forward the rendered message unchanged using the configured provider's API key or OAuth token and await acceptance before returning the nonce; failures omit it. Acceptance is not handset delivery. Platform/key prerequisites and HTTP outcomes are defined in the [contract](../docs/CONTRACT.md#evaluation-generic-shutter). diff --git a/dotnet/Src/AppConfig.cs b/dotnet/Src/AppConfig.cs index 8c02454..2713864 100644 --- a/dotnet/Src/AppConfig.cs +++ b/dotnet/Src/AppConfig.cs @@ -6,6 +6,12 @@ public sealed class AppConfig public string? ExpectedKeyId { get; init; } public string? ProviderName { get; init; } public string? ProviderEndpoint { get; init; } + public string? ProviderChannel { get; init; } + public string? ProviderAuthMode { get; init; } + public string? ProviderTenantId { get; init; } + public string? ProviderScope { get; init; } + public string? OutboundClientId { get; init; } + public string? OutboundManagedIdentityClientId { get; init; } // Keep the raw value; DispatchEngine owns timeout normalization. public string? ProviderTimeoutMs { get; init; } @@ -15,6 +21,12 @@ public sealed class AppConfig ExpectedKeyId = env.Get("EPP_ENCRYPTION_KEY_ID"), ProviderName = env.Get("EPP_PROVIDER_NAME")?.Trim().ToLowerInvariant(), ProviderEndpoint = env.Get("EPP_PROVIDER_ENDPOINT"), + ProviderChannel = env.Get("EPP_PROVIDER_CHANNEL")?.Trim().ToLowerInvariant(), + ProviderAuthMode = env.Get("EPP_PROVIDER_AUTH_MODE")?.Trim(), + ProviderTenantId = env.Get("EPP_PROVIDER_TENANT_ID")?.Trim(), + ProviderScope = env.Get("EPP_PROVIDER_SCOPE")?.Trim(), + OutboundClientId = env.Get("EPP_OUTBOUND_CLIENT_ID")?.Trim(), + OutboundManagedIdentityClientId = env.Get("EPP_OUTBOUND_MI_CLIENT_ID")?.Trim(), ProviderTimeoutMs = env.Get("EPP_PROVIDER_TIMEOUT_MS"), }; } \ No newline at end of file diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index fdf5cec..f0de37c 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -1,3 +1,5 @@ +using Azure.Core; +using Azure.Identity; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -216,6 +218,9 @@ public sealed class DispatchEngine private readonly ISecretResolver _secrets; private readonly IHttpClientFactory _httpFactory; private readonly IEnv _env; + private readonly object _oauthLock = new(); + private TokenCredential? _oauthCredential; + private string? _oauthCredentialConfig; public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null) { @@ -242,16 +247,23 @@ public async Task DispatchAsync(DispatchRequest dispatch, string if (channel == "voice" && manifest.RequiresTextToVoice && dispatch.TextToVoice?.IsComplete != true) return new DispatchResult(400, FailBody(providerId, channel, "incomplete voice context", dispatch, requestId)); - if (manifest.Auth.Mode != "apiKey") - return new DispatchResult(502, FailBody(providerId, channel, "unsupported provider auth mode", dispatch, requestId)); + if (!string.IsNullOrEmpty(config.ProviderChannel) && config.ProviderChannel != channel) + return new DispatchResult(400, new { status = "error", provider = providerId, reason = "channel not configured", requestId }); + if (!string.IsNullOrEmpty(config.ProviderAuthMode) && config.ProviderAuthMode != manifest.Auth.Mode) + return new DispatchResult(502, FailBody(providerId, channel, "provider authentication mismatch", dispatch, requestId)); ProviderCredential credential; - try { credential = await ResolveCredentialAsync(manifest.Auth); } + try { credential = await ResolveCredentialAsync(manifest.Auth, config); } catch { return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); } - var identityRequired = !string.IsNullOrEmpty(manifest.Auth.IdentityKeyVaultSecretName); - var credentialUnavailable = string.IsNullOrEmpty(credential.Secret) - || (identityRequired && string.IsNullOrEmpty(credential.Identity)); + var identityRequired = credential.Mode == "apiKey" && !string.IsNullOrEmpty(manifest.Auth.IdentityKeyVaultSecretName); + var credentialUnavailable = credential.Mode switch + { + "apiKey" => string.IsNullOrEmpty(credential.Secret) + || (identityRequired && string.IsNullOrEmpty(credential.Identity)), + "oauth" => string.IsNullOrEmpty(credential.AccessToken), + _ => true, + }; if (credentialUnavailable) return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); @@ -296,11 +308,44 @@ public async Task DispatchAsync(DispatchRequest dispatch, string } } - private async Task ResolveCredentialAsync(AuthConfig auth) + private async Task ResolveCredentialAsync(AuthConfig auth, AppConfig config) { - var secret = await _secrets.ResolveAsync(auth.KeyVaultSecretName); - var identity = string.IsNullOrEmpty(auth.IdentityKeyVaultSecretName) ? string.Empty : await _secrets.ResolveAsync(auth.IdentityKeyVaultSecretName); - return new ProviderCredential("apiKey", Secret: secret, Identity: identity); + if (auth.Mode == "apiKey") + { + var secret = await _secrets.ResolveAsync(auth.KeyVaultSecretName); + var identity = string.IsNullOrEmpty(auth.IdentityKeyVaultSecretName) ? string.Empty : await _secrets.ResolveAsync(auth.IdentityKeyVaultSecretName); + return new ProviderCredential("apiKey", Secret: secret, Identity: identity); + } + if (auth.Mode != "oauth" || string.IsNullOrEmpty(config.ProviderTenantId) + || string.IsNullOrEmpty(config.ProviderScope) || string.IsNullOrEmpty(config.OutboundClientId) + || string.IsNullOrEmpty(config.OutboundManagedIdentityClientId)) + throw new InvalidOperationException("unsupported or incomplete provider authentication"); + + var credentialConfig = string.Join("|", config.ProviderTenantId, config.OutboundClientId, config.OutboundManagedIdentityClientId); + TokenCredential providerCredential; + lock (_oauthLock) + { + if (_oauthCredential is null || _oauthCredentialConfig != credentialConfig) + { + var managedIdentity = new ManagedIdentityCredential(config.OutboundManagedIdentityClientId); + _oauthCredential = new ClientAssertionCredential( + config.ProviderTenantId, + config.OutboundClientId, + async cancellationToken => + { + var assertion = await managedIdentity.GetTokenAsync( + new TokenRequestContext(new[] { "api://AzureADTokenExchange/.default" }), + cancellationToken); + return assertion.Token; + }); + _oauthCredentialConfig = credentialConfig; + } + providerCredential = _oauthCredential; + } + var token = await providerCredential.GetTokenAsync( + new TokenRequestContext(new[] { config.ProviderScope }), + CancellationToken.None); + return new ProviderCredential("oauth", AccessToken: token.Token); } internal static int NormalizeProviderTimeoutMs(string? value) diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index e4beeb6..a65a058 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -39,7 +39,7 @@ public sealed record TextToVoice( public override string ToString() => nameof(TextToVoice); } -public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null); +public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, string? AccessToken = null); public sealed record ProviderHttpRequest(string Url, string Method, Dictionary Headers, string Body); diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index a65fdea..d1455d6 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -6,7 +6,7 @@ public sealed class SopranoProvider : IProviderAdapter { public ProviderManifest Manifest { get; } = new( Id: "soprano", - Auth: new AuthConfig("apiKey", KeyVaultSecretName: "soprano-api-key", IdentityKeyVaultSecretName: "soprano-api-id"), + Auth: new AuthConfig("oauth"), ResponseMapping: new Dictionary { ["ENROUTE"] = Outcome.Continue, @@ -29,8 +29,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc { ["Content-Type"] = "application/json", ["Accept"] = "application/json", - ["X-MEMS-API-ID"] = credential.Identity ?? string.Empty, - ["X-MEMS-API-Key"] = credential.Secret ?? string.Empty, + ["Authorization"] = "Bearer " + credential.AccessToken, }; var body = new Dictionary { @@ -50,7 +49,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc body["text"] = dispatch.Message; } - return new ProviderHttpRequest($"{endpoint.TrimEnd('/')}/messages/omnimsg", "POST", headers, JsonSerializer.Serialize(body)); + return new ProviderHttpRequest(endpoint, "POST", headers, JsonSerializer.Serialize(body)); } public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs index 0875ee8..02e8f30 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -45,7 +45,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc ["Content-Type"] = "application/json", ["Accept"] = "application/json", }; - return new ProviderHttpRequest($"{endpoint.TrimEnd('/')}/integration/msft/cyot", "POST", headers, JsonSerializer.Serialize(body)); + return new ProviderHttpRequest(endpoint, "POST", headers, JsonSerializer.Serialize(body)); } public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 17b8f8f..53c5d73 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -13,16 +13,15 @@ private static DispatchRequest Request(string channel = "sms") => [Theory] [InlineData("sms")] [InlineData("voice")] - public void SopranoUsesExactOmnimsgContract(string channel) + public void SopranoUsesSelectedEndpointAndOAuth(string channel) { var dispatch = Request(channel) with { TextToVoice = new TextToVoice("Your code is", "001234", "en-US") }; - var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", dispatch, - new ProviderCredential("apiKey", "test-key", "test-id"), new TestEnv()); - Assert.Equal("https://provider.example/cgpapi/messages/omnimsg", request.Url); + var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/oauth/messages", dispatch, + new ProviderCredential("oauth", AccessToken: "provider-token"), new TestEnv()); + Assert.Equal("https://provider.example/oauth/messages", request.Url); Assert.Equal("POST", request.Method); - Assert.Equal(4, request.Headers.Count); - Assert.Equal("test-id", request.Headers["X-MEMS-API-ID"]); - Assert.Equal("test-key", request.Headers["X-MEMS-API-Key"]); + Assert.Equal(3, request.Headers.Count); + Assert.Equal("Bear" + "er provider-token", request.Headers["Authorization"]); Assert.Equal("application/json", request.Headers["Accept"]); Assert.Equal("application/json", request.Headers["Content-Type"]); var expected = new Dictionary @@ -87,9 +86,9 @@ public void OtherProvidersKeepTheirStaticAuthenticationAndProtocols() public void TelesignUsesEppJsonContract(string channel, string? locale) { var dispatch = Request(channel) with { Locale = locale }; - var request = new TelesignProvider().BuildRequest(channel, "https://verify.telesign.com///", dispatch, + var request = new TelesignProvider().BuildRequest(channel, $"https://verify.telesign.com/epp/{channel}", dispatch, new ProviderCredential("apiKey", "test-key", "test-id"), new TestEnv()); - Assert.Equal("https://verify.telesign.com/integration/msft/cyot", request.Url); + Assert.Equal($"https://verify.telesign.com/epp/{channel}", request.Url); Assert.Equal("POST", request.Method); Assert.Equal(3, request.Headers.Count); Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test-id:test-key")), request.Headers["Authorization"]); diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index c742349..f02d8bd 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -23,7 +23,7 @@ public class EngineTests public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() { using var rig = new HandlerRig(); - Assert.Equal("soprano", AppConfig.Read(rig.Env).ProviderName); + Assert.Equal("infobip", AppConfig.Read(rig.Env).ProviderName); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); rig.Http.Respond = cancellation => @@ -31,8 +31,7 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() entered.TrySetResult(); return release.Task.WaitAsync(cancellation); }; - var voice = new { beforePasswordText = " Your code is ", password = "001234", language = "en-US" }; - var pending = rig.Invoke(channel: "voice", deliveryOverrides: JsonSerializer.SerializeToElement(new { textToVoice = voice })); + var pending = rig.Invoke(channel: "sms"); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -40,16 +39,11 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() } finally { - release.TrySetResult(Json(201, "{\"status\":\"ENROUTE\"}")); + release.TrySetResult(Json(200, "{\"messages\":[{\"messageId\":\"id\",\"status\":{\"groupName\":\"PENDING\"}}]}")); } AssertAccepted(await pending); using var body = JsonDocument.Parse(rig.Http.Body!); - Assert.False(body.RootElement.TryGetProperty("text", out _)); - Assert.Equal(JsonSerializer.Serialize(voice), body.RootElement.GetProperty("voice").GetProperty("text2voice").GetRawText()); - Assert.Equal("voice", body.RootElement.GetProperty("messageTypes")[0].GetString()); - Assert.Equal("private-api-id", rig.Http.Headers["X-MEMS-API-ID"]); - Assert.Equal("private-api-key", rig.Http.Headers["X-MEMS-API-Key"]); - Assert.False(rig.Http.Headers.ContainsKey("Authorization")); + Assert.Equal(Message, body.RootElement.GetProperty("messages")[0].GetProperty("content").GetProperty("text").GetString()); Assert.Equal(1, rig.Http.Calls); var log = Assert.Single(rig.Log.Messages); var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Correlation)))[..16].ToLowerInvariant(); @@ -68,6 +62,8 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() public async Task IncompleteVoiceFailsBeforeSecretsOrHttp(string speech) { using var rig = new HandlerRig(); + rig.Env["EPP_PROVIDER_NAME"] = "soprano"; + rig.Env["EPP_PROVIDER_AUTH_MODE"] = "oauth"; var overrides = JsonSerializer.SerializeToElement(new { textToVoice = JsonSerializer.Deserialize(speech) }); AssertFailure(rig, await rig.Invoke(channel: "voice", deliveryOverrides: overrides), 400); Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls)); @@ -108,6 +104,9 @@ public async Task ResponseBodyTimeoutCancelsWithoutRetryOrSuccessNonce() public async Task MissingIdentityOrKeyFailsClosedBeforeHttp() { using var rig = new HandlerRig(); + rig.Env["EPP_PROVIDER_NAME"] = "telesign"; + rig.Env["EPP_PROVIDER_ENDPOINT"] = "https://verify.telesign.com/epp/sms"; + rig.Env["EPP_PROVIDER_AUTH_MODE"] = "apiKey"; rig.Secrets.Identity = ""; AssertFailure(rig, await rig.Invoke(), 502); rig.Secrets.Identity = "private-api-id"; @@ -269,8 +268,8 @@ public HandlerRig() { Env = new TestEnv { - ["EPP_PROVIDER_NAME"] = "soprano", - ["EPP_PROVIDER_ENDPOINT"] = "https://provider.example/cgpapi", + ["EPP_PROVIDER_NAME"] = "infobip", + ["EPP_PROVIDER_ENDPOINT"] = "https://provider.example", ["EPP_PROVIDER_TIMEOUT_MS"] = "2500", }; var registry = new ProviderRegistry(new IProviderAdapter[] @@ -314,7 +313,7 @@ private sealed class TestSecrets : ISecretResolver public Task ResolveAsync(string? name) { Calls++; - return Task.FromResult(name == "soprano-api-id" ? Identity : Secret); + return Task.FromResult(name == "telesign-customer-id" ? Identity : Secret); } } @@ -324,7 +323,7 @@ private sealed class TestHttp : HttpMessageHandler, IHttpClientFactory public string? Body { get; private set; } public Dictionary Headers { get; private set; } = new(StringComparer.OrdinalIgnoreCase); public Func> Respond { get; set; } = - _ => Task.FromResult(Json(201, "{\"status\":\"ACCEPTED\"}")); + _ => Task.FromResult(Json(200, "{\"messages\":[{\"messageId\":\"id\",\"status\":{\"groupName\":\"PENDING\"}}]}")); public HttpClient CreateClient(string name) => new(this, disposeHandler: false); protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { diff --git a/javascript/README.md b/javascript/README.md index be301f9..1b15a0d 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -46,7 +46,8 @@ the test-key placeholder in this minimal setup: } ``` -For live delivery, add `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and `KEY_VAULT_URL` to `Values`. +For live delivery, add `EPP_PROVIDER_NAME`, the complete selected `EPP_PROVIDER_ENDPOINT`, and the +matching provider authentication settings to `Values`. Add `EPP_PROVIDER_ACCOUNT_NAME` and any adapter-specific options only when required. Optional `EPP_PROVIDER_TIMEOUT_MS` is a string such as `"1500"`. Replace placeholders; do not put API keys in this file. See the [complete variable table](../README.md#configure-environment-variables). @@ -64,9 +65,8 @@ configure the current shared engine. Use `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDP that are actually read, such as a service-plan ID or voice selection. Private integration helpers may load settings from another location or use test credential variables, but the Function itself does not. -For the omnimsg adapter, the configured base ends in `/cgpapi`; the adapter appends `/messages/omnimsg` -for SMS and voice. QA4 is the test environment; select the provider-approved production base separately. -The base URL is not hard-coded and changing local settings does not change an already deployed app. +The Soprano adapter uses the configured complete endpoint and an OAuth bearer token. The Telesign +adapter uses the configured complete endpoint and API-key credentials from Key Vault. For Azure, set these application variables on the Function App/slot's **Environment variables → App settings** page and use a Key Vault reference for the private PEM. The provider-secret resolver uses @@ -87,7 +87,7 @@ works for every provider without provider configuration, provider Key Vault read platform authentication on Azure and handler decryption still run. No diagnostic environment flag is needed. See the [evaluation contract](../docs/CONTRACT.md#evaluation-generic-shutter) for authentication/key prerequisites. -Live requests use the configured provider's API key and await acceptance before returning the nonce. +Live requests use the configured provider's API key or OAuth token and await acceptance before returning the nonce. Acceptance is not handset delivery; failures omit the nonce, and timeouts must not trigger blind retries. The shared contract defines validation, HTTP outcomes and privacy-safe logging. diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js index 6b216a5..90c139e 100644 --- a/javascript/src/functions/config.js +++ b/javascript/src/functions/config.js @@ -12,6 +12,12 @@ class AppConfig { this.expectedKeyId = env.EPP_ENCRYPTION_KEY_ID || ''; this.providerName = (env.EPP_PROVIDER_NAME || '').trim().toLowerCase(); this.providerEndpoint = env.EPP_PROVIDER_ENDPOINT || ''; + this.providerChannel = (env.EPP_PROVIDER_CHANNEL || '').trim().toLowerCase(); + this.providerAuthMode = (env.EPP_PROVIDER_AUTH_MODE || '').trim(); + this.providerTenantId = (env.EPP_PROVIDER_TENANT_ID || '').trim(); + this.providerScope = (env.EPP_PROVIDER_SCOPE || '').trim(); + this.outboundClientId = (env.EPP_OUTBOUND_CLIENT_ID || '').trim(); + this.outboundManagedIdentityClientId = (env.EPP_OUTBOUND_MI_CLIENT_ID || '').trim(); this.providerTimeoutMs = env.EPP_PROVIDER_TIMEOUT_MS || ''; this.keyVaultUrl = (env.KEY_VAULT_URL || '').trim(); this.managedIdentityClientId = (env.AZURE_CLIENT_ID || '').trim(); diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index d36750f..f21970b 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -6,7 +6,7 @@ const crypto = require('crypto'); const { compactDecrypt } = require('jose'); -const { ManagedIdentityCredential } = require('@azure/identity'); +const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('./config'); const { DeliveryContext, TextToVoice } = require('./models'); @@ -163,6 +163,8 @@ function getProvider(providerId) { let keyVaultSecretClient = null; let keyVaultClientConfig; const secretCache = new Map(); +let oauthCredential = null; +let oauthCredentialConfig; function getKeyVaultSecretClient(config) { const cacheKey = JSON.stringify([config.keyVaultUrl, config.managedIdentityClientId]); @@ -197,15 +199,38 @@ async function resolveSecretValue(keyVaultSecretName, config) { async function resolveProviderCredential(authConfiguration = {}, config) { const { mode = 'apiKey' } = authConfiguration; - if (mode !== 'apiKey') throw new Error('unsupported provider authentication'); - - const [secret, identity] = await Promise.all([ - resolveSecretValue(authConfiguration.keyVaultSecretName, config), - authConfiguration.identityKeyVaultSecretName - ? resolveSecretValue(authConfiguration.identityKeyVaultSecretName, config) - : Promise.resolve(''), + if (mode === 'apiKey') { + const [secret, identity] = await Promise.all([ + resolveSecretValue(authConfiguration.keyVaultSecretName, config), + authConfiguration.identityKeyVaultSecretName + ? resolveSecretValue(authConfiguration.identityKeyVaultSecretName, config) + : Promise.resolve(''), + ]); + return { mode: 'apiKey', secret, identity }; + } + if (mode !== 'oauth' || !config.providerTenantId || !config.providerScope + || !config.outboundClientId || !config.outboundManagedIdentityClientId) { + throw new Error('unsupported or incomplete provider authentication'); + } + const credentialConfig = JSON.stringify([ + config.providerTenantId, config.outboundClientId, config.outboundManagedIdentityClientId, ]); - return { mode: 'apiKey', secret, identity }; + if (!oauthCredential || oauthCredentialConfig !== credentialConfig) { + const assertionIdentity = new ManagedIdentityCredential(config.outboundManagedIdentityClientId); + oauthCredential = new ClientAssertionCredential( + config.providerTenantId, + config.outboundClientId, + async () => { + const assertion = await assertionIdentity.getToken('api://AzureADTokenExchange/.default'); + if (!assertion?.token) throw new Error('managed identity assertion unavailable'); + return assertion.token; + }, + ); + oauthCredentialConfig = credentialConfig; + } + const accessToken = await oauthCredential.getToken(config.providerScope); + if (!accessToken?.token) throw new Error('provider OAuth token unavailable'); + return { mode: 'oauth', accessToken: accessToken.token }; } // Status mappings may restrict HTTP success, but cannot turn failed HTTP into Continue. @@ -306,6 +331,12 @@ async function sendViaProvider(providerEntry, dispatch, options) { if (!['sms', 'voice'].includes(channel)) { return { httpStatus: 400, body: { status: 'error', reason: 'unsupported channel', requestId } }; } + if (config.providerChannel && config.providerChannel !== channel) { + return { httpStatus: 400, body: { status: 'error', provider: providerId, reason: 'channel not configured', requestId } }; + } + if (config.providerAuthMode && config.providerAuthMode !== manifest.auth?.mode) { + return { httpStatus: 502, body: failBody(providerId, channel, 'provider authentication mismatch', dispatch, requestId) }; + } if (channel === 'voice' && manifest.requiresTextToVoice && (!(dispatch.textToVoice instanceof TextToVoice) || !dispatch.textToVoice.isComplete)) { @@ -323,9 +354,10 @@ async function sendViaProvider(providerEntry, dispatch, options) { } catch { // Configuration and secret lookup failures share a generic failure response. } - const identityRequired = !!manifest.auth?.identityKeyVaultSecretName; - const credentialUnavailable = !credential || !credential.secret - || (identityRequired && !credential.identity); + const identityRequired = credential?.mode === 'apiKey' && !!manifest.auth?.identityKeyVaultSecretName; + const credentialUnavailable = !credential + || (credential.mode === 'apiKey' && (!credential.secret || (identityRequired && !credential.identity))) + || (credential.mode === 'oauth' && !credential.accessToken); if (credentialUnavailable) { return { httpStatus: 502, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; } @@ -409,4 +441,5 @@ module.exports = { outcomeToHttpStatus, parseProviderTimeout, isValidProviderUrl, + resolveProviderCredential, }; diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index 8696c50..db77475 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -9,11 +9,7 @@ const { ParsedResponse, TextToVoice } = require('../models'); const manifest = { id: 'soprano', requiresTextToVoice: true, - auth: { - mode: 'apiKey', - keyVaultSecretName: 'soprano-api-key', - identityKeyVaultSecretName: 'soprano-api-id', - }, + auth: { mode: 'oauth' }, responseMapping: { ENROUTE: 'Continue', ACCEPTED: 'Continue', @@ -30,13 +26,10 @@ const manifest = { }; function buildRequest({ channel, endpoint, dispatch, credential }) { - let base = endpoint; - while (base.endsWith('/')) base = base.slice(0, -1); const headers = { 'Content-Type': 'application/json', Accept: 'application/json', - 'X-MEMS-API-ID': credential.identity, - 'X-MEMS-API-Key': credential.secret, + Authorization: `Bearer ${credential.accessToken}`, }; let destination = String(dispatch.destination || ''); while (destination.startsWith('+')) destination = destination.slice(1); @@ -53,7 +46,7 @@ function buildRequest({ channel, endpoint, dispatch, credential }) { } else { body.text = dispatch.message; } - return { url: `${base}/messages/omnimsg`, method: 'POST', headers, body: JSON.stringify(body) }; + return { url: endpoint, method: 'POST', headers, body: JSON.stringify(body) }; } function parseResponse({ httpStatus, ok, json }) { diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index bfbad48..f50544f 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -41,7 +41,7 @@ function buildRequest({ channel, endpoint, dispatch, credential }) { const message = { text: dispatch.message }; if (typeof dispatch.locale === 'string' && dispatch.locale.trim()) message.language = dispatch.locale; return { - url: `${endpoint.replace(/\/+$/, '')}/integration/msft/cyot`, + url: endpoint, method: 'POST', headers: { Authorization: authorization, diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 8ba5991..b1e6899 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -2,6 +2,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); +const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { AppConfig, readConfig } = require('../src/functions/config'); const { DeliveryContext, TextToVoice, ParsedResponse } = require('../src/functions/models'); @@ -9,7 +10,7 @@ const fixtures = require('../../tests/fixtures/contract.json'); const { inspect } = require('node:util'); const { dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus, - parseEnvelope, parseProviderTimeout, isValidProviderUrl, contextToDispatch, + parseEnvelope, parseProviderTimeout, isValidProviderUrl, contextToDispatch, resolveProviderCredential, } = require('../src/functions/dispatch'); const dispatch = { destination: '+15551234567', message: ' Your code is 918273.\n', channel: 'sms', messageId: 'message-id', correlationId: 'correlation-id' }; @@ -80,12 +81,14 @@ test('provider URLs and timeouts retain representative safety boundaries', () => assert.equal(parseProviderTimeout('9999'), 2500); }); -test('omnimsg preserves its API-key request and normalizes acceptance', () => { - const request = getProvider('soprano').adapter.buildRequest({ ...input, env: undefined, endpoint: `${input.endpoint}/cgpapi///` }); - assert.equal(request.url, 'https://provider.example/cgpapi/messages/omnimsg'); +test('Soprano uses the selected endpoint and OAuth bearer token', () => { + const request = getProvider('soprano').adapter.buildRequest({ ...input, env: undefined, + endpoint: `${input.endpoint}/oauth/messages`, + credential: { mode: 'oauth', accessToken: 'provider-token' } }); + assert.equal(request.url, 'https://provider.example/oauth/messages'); assert.equal(request.method, 'POST'); assert.deepEqual(request.headers, { 'Content-Type': 'application/json', Accept: 'application/json', - 'X-MEMS-API-ID': 'id', 'X-MEMS-API-Key': 'key' }); + Authorization: 'Bear' + 'er provider-token' }); assert.deepEqual(JSON.parse(request.body), { text: dispatch.message, destination: '15551234567', messageTypes: ['sms'], correlationId: 'correlation-id', shutterMode: false }); const response = getProvider('soprano').adapter.parseResponse({ httpStatus: 201, ok: true, @@ -95,14 +98,15 @@ test('omnimsg preserves its API-key request and normalizes acceptance', () => { assert.equal(inspect(response), '[ParsedResponse]'); }); -test('Soprano Voice sends structured speech with API-key headers only', () => { +test('Soprano Voice sends structured speech with OAuth', () => { const textToVoice = TextToVoice.fromPayload({ beforePasswordText: ' Your code is ', password: '001234', language: 'en-US', unexpected: 'must-not-be-forwarded' }); const request = getProvider('soprano').adapter.buildRequest({ ...input, channel: 'voice', - dispatch: { ...dispatch, textToVoice }, credential: { ...input.credential, token: 'ignored-token' } }); - assert.equal(request.url, `${input.endpoint}/messages/omnimsg`); + endpoint: `${input.endpoint}/oauth/voice`, dispatch: { ...dispatch, textToVoice }, + credential: { mode: 'oauth', accessToken: 'provider-token' } }); + assert.equal(request.url, `${input.endpoint}/oauth/voice`); assert.deepEqual(request.headers, { 'Content-Type': 'application/json', Accept: 'application/json', - 'X-MEMS-API-ID': 'id', 'X-MEMS-API-Key': 'key' }); + Authorization: 'Bear' + 'er provider-token' }); assert.deepEqual(JSON.parse(request.body), { destination: '15551234567', messageTypes: ['voice'], correlationId: 'correlation-id', shutterMode: false, voice: { text2voice: { beforePasswordText: ' Your code is ', password: '001234', language: 'en-US' } } }); @@ -141,12 +145,12 @@ test('App-auth SMS preserves its request and normalizes acceptance', () => { providerMessageId: 'message-id', providerStatusName: 'PENDING' })); }); -test('Telesign EPP uses the same Basic-auth JSON contract for SMS and voice', () => { +test('Telesign EPP uses the selected endpoint with the same Basic-auth JSON contract for SMS and voice', () => { for (const [channel, locale] of [['sms', 'en'], ['voice', 'en'], ['sms', undefined], ['sms', ''], ['sms', { untrusted: true }]]) { const request = getProvider('telesign').adapter.buildRequest({ ...input, channel, - endpoint: 'https://verify.telesign.com///', dispatch: { ...dispatch, locale } }); - assert.equal(request.url, 'https://verify.telesign.com/integration/msft/cyot'); + endpoint: `https://verify.telesign.com/epp/${channel}`, dispatch: { ...dispatch, locale } }); + assert.equal(request.url, `https://verify.telesign.com/epp/${channel}`); assert.equal(request.method, 'POST'); assert.deepEqual(request.headers, { Authorization: `Basic ${Buffer.from('id:key').toString('base64')}`, 'Content-Type': 'application/json', Accept: 'application/json' }); @@ -216,11 +220,11 @@ test('response parsing and HTTP mapping fail closed, including malformed status/ } }); -test('missing key/identity and an unsafe final voice URL make zero HTTP calls', async (t) => { +test('missing API-key or OAuth settings and an unsafe final voice URL make zero HTTP calls', async (t) => { const settings = { KEY_VAULT_URL: 'https://unit-test.vault.azure.net', EPP_PROVIDER_ENDPOINT: input.endpoint, SINCH_VOICE_ENDPOINT: 'http://unsafe.example' }; const getSecret = t.mock.method(SecretClient.prototype, 'getSecret', async (name) => ({ - value: ['soprano-api-id', 'telesign-api-key'].includes(name) ? '' : 'fixture-key', + value: name === 'telesign-api-key' ? '' : 'fixture-key', })); const fetchMock = t.mock.method(global, 'fetch', () => assert.fail('unexpected HTTP')); for (const [providerName, channel, reason] of [ @@ -244,3 +248,17 @@ test('missing key/identity and an unsafe final voice URL make zero HTTP calls', assert.equal(new Set(getSecret.mock.calls.map((call) => call.this)).size, 3); assert.equal(fetchMock.mock.callCount(), 0); }); + +test('Soprano OAuth requests the selected provider scope', async (t) => { + t.mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ token: 'assertion-token' })); + const providerToken = t.mock.method(ClientAssertionCredential.prototype, 'getToken', async () => ({ token: 'provider-token' })); + const config = readConfig({ + EPP_PROVIDER_TENANT_ID: '11111111-1111-1111-1111-111111111111', + EPP_PROVIDER_SCOPE: 'api://provider/.default', + EPP_OUTBOUND_CLIENT_ID: '22222222-2222-2222-2222-222222222222', + EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333', + }); + const credential = await resolveProviderCredential({ mode: 'oauth' }, config); + assert.deepEqual(credential, { mode: 'oauth', accessToken: 'provider-token' }); + assert.equal(providerToken.mock.calls[0].arguments[0], 'api://provider/.default'); +}); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 8d488fe..05d0d3c 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -5,6 +5,7 @@ const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const Module = require('node:module'); const { CompactEncrypt } = require('jose'); +const { ClientAssertionCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const fixtures = require('../../tests/fixtures/contract.json'); @@ -25,7 +26,9 @@ try { } const envKeys = ['EPP_ENCRYPTION_KEY_ID', 'AZURE_CLIENT_ID', 'EPP_PROVIDER_NAME', 'EPP_PROVIDER_ENDPOINT', - 'EPP_PROVIDER_TIMEOUT_MS', 'EPP_LOG_PLAINTEXT', 'KEY_VAULT_URL', 'EPP_DECRYPTION_KEY_PEM']; + 'EPP_PROVIDER_TIMEOUT_MS', 'EPP_PROVIDER_AUTH_MODE', 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_SCOPE', + 'EPP_OUTBOUND_CLIENT_ID', 'EPP_OUTBOUND_MI_CLIENT_ID', 'EPP_LOG_PLAINTEXT', 'KEY_VAULT_URL', + 'EPP_DECRYPTION_KEY_PEM']; let savedEnv; let fetchMock; let getSecret; @@ -37,7 +40,12 @@ beforeEach(() => { Object.assign(process.env, { EPP_LOG_PLAINTEXT: 'true', EPP_DECRYPTION_KEY_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }), KEY_VAULT_URL: 'https://unit-test.vault.azure.net', EPP_PROVIDER_NAME: 'soprano', - EPP_PROVIDER_ENDPOINT: 'https://provider.example/cgpapi/' }); + EPP_PROVIDER_ENDPOINT: 'https://provider.example/epp/messages', EPP_PROVIDER_AUTH_MODE: 'oauth', + EPP_PROVIDER_TENANT_ID: '11111111-1111-1111-1111-111111111111', + EPP_PROVIDER_SCOPE: 'api://provider/.default', + EPP_OUTBOUND_CLIENT_ID: '22222222-2222-2222-2222-222222222222', + EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333' }); + mock.method(ClientAssertionCredential.prototype, 'getToken', async () => ({ token: 'PRIVATE-OAUTH-TOKEN' })); getSecret = mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'PRIVATE-API-KEY' })); fetchMock = mock.method(global, 'fetch', async () => ({ ok: true, status: 201, text: async () => JSON.stringify({ status: 'ENROUTE', id: 'PRIVATE-ID', description: 'PRIVATE-STATUS' }) })); @@ -177,7 +185,7 @@ test('SMS/voice preserve content and correlation without reflecting headers or l assert.equal(sent.voice, undefined); } assert.deepEqual(init.headers, { 'Content-Type': 'application/json', Accept: 'application/json', - 'X-MEMS-API-ID': 'PRIVATE-API-KEY', 'X-MEMS-API-Key': 'PRIVATE-API-KEY' }); + Authorization: 'Bear' + 'er PRIVATE-OAUTH-TOKEN' }); assert.equal(init.redirect, 'manual'); assert.equal(logs.length, 1); assert.deepEqual(Object.keys(logs[0]).sort(), ['correlationId', 'elapsedMs', 'evaluation', 'httpStatus', 'requestId']); @@ -192,7 +200,8 @@ test('SMS/voice preserve content and correlation without reflecting headers or l test('Telesign EPP sends decrypted SMS and voice content with Basic auth and private logs', async () => { process.env.EPP_PROVIDER_NAME = 'telesign'; - process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com'; + process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com/epp/send'; + process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; for (const [channel, name, code] of [[1, 'sms', 290], [2, 'voice', 100], [1, 'sms', 3001], [2, 'voice', 3001]]) { fetchMock.mock.mockImplementation(async () => ({ ok: true, status: 200, @@ -202,7 +211,7 @@ test('Telesign EPP sends decrypted SMS and voice content with Basic auth and pri assert.deepEqual(result.jsonBody, { nonce: delivery.nonce, correlationId: 'correlation-id', providerStatus: 'accepted' }); assert.equal(result.status, 200); const [url, init] = fetchMock.mock.calls.at(-1).arguments; - assert.equal(url, 'https://verify.telesign.com/integration/msft/cyot'); + assert.equal(url, 'https://verify.telesign.com/epp/send'); assert.deepEqual(JSON.parse(init.body), { recipient: { phone_number: delivery.phoneNumber }, message: { text: delivery.message, language: delivery.locale }, channels: [{ channel: name }], correlation_id: 'correlation-id' }); assert.deepEqual(init.headers, { Authorization: `Basic ${Buffer.from('PRIVATE-API-KEY:PRIVATE-API-KEY').toString('base64')}`, @@ -230,6 +239,7 @@ test('Telesign evaluation never sends and invalid recipients never reach HTTP', test('Telesign missing status or upstream failure never acknowledges delivery', async () => { process.env.EPP_PROVIDER_NAME = 'telesign'; process.env.EPP_PROVIDER_ENDPOINT = 'https://verify.telesign.com'; + process.env.EPP_PROVIDER_AUTH_MODE = 'apiKey'; for (const [status, payload, expected] of [[200, {}, 502], [500, { status: { code: 290 } }, 502], [429, { status: { code: 290 } }, 429], [500, { status: { code: 3001 } }, 502], [401, { status: { code: 3001 } }, 401], [429, { status: { code: 3001 } }, 429]]) { diff --git a/python/README.md b/python/README.md index 3fe5712..329bd0c 100644 --- a/python/README.md +++ b/python/README.md @@ -45,7 +45,8 @@ For local evaluation, start Azurite and replace the test-key placeholder in this } ``` -For live delivery, add `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and `KEY_VAULT_URL` to `Values`. +For live delivery, add `EPP_PROVIDER_NAME`, the complete selected `EPP_PROVIDER_ENDPOINT`, and the +matching provider authentication settings to `Values`. Add `EPP_PROVIDER_ACCOUNT_NAME` and any adapter-specific options only when required. Keep values as strings, including optional `EPP_PROVIDER_TIMEOUT_MS: "1500"`. Replace placeholders; provider API keys belong in the manifest-named Key Vault secrets, not this file. See the @@ -74,7 +75,7 @@ authenticate SAS: anyone with the public key can encrypt a request, and a fixed Use incoming `mode: 2` or `mode: "evaluation"` as the generic shutter for every provider: platform authentication on Azure, handler validation and decryption run, but provider lookup, provider Key Vault reads and provider HTTP do not. No provider configuration or diagnostic environment flag is required. -Live requests forward the rendered message unchanged using the configured provider's API key and +Live requests forward the rendered message unchanged using the configured provider's API key or OAuth token and await acceptance before returning the nonce; failures omit it. Acceptance is not handset delivery. Platform/key prerequisites and HTTP outcomes are defined in the [contract](../docs/CONTRACT.md#evaluation-generic-shutter). diff --git a/python/src/config.py b/python/src/config.py index c1fd9f6..3627961 100644 --- a/python/src/config.py +++ b/python/src/config.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from collections.abc import Mapping from dataclasses import dataclass @@ -9,6 +11,12 @@ class AppConfig: expected_key_id: str | None provider_name: str provider_endpoint: str | None + provider_channel: str + provider_auth_mode: str + provider_tenant_id: str + provider_scope: str + outbound_client_id: str + outbound_managed_identity_client_id: str provider_timeout_ms: str | None env: Mapping[str, str] @@ -20,6 +28,12 @@ def read_config(env: Mapping[str, str] | None = None) -> AppConfig: expected_key_id=env.get("EPP_ENCRYPTION_KEY_ID"), provider_name=(env.get("EPP_PROVIDER_NAME") or "").strip().lower(), provider_endpoint=env.get("EPP_PROVIDER_ENDPOINT"), + provider_channel=(env.get("EPP_PROVIDER_CHANNEL") or "").strip().lower(), + provider_auth_mode=(env.get("EPP_PROVIDER_AUTH_MODE") or "").strip(), + provider_tenant_id=(env.get("EPP_PROVIDER_TENANT_ID") or "").strip(), + provider_scope=(env.get("EPP_PROVIDER_SCOPE") or "").strip(), + outbound_client_id=(env.get("EPP_OUTBOUND_CLIENT_ID") or "").strip(), + outbound_managed_identity_client_id=(env.get("EPP_OUTBOUND_MI_CLIENT_ID") or "").strip(), provider_timeout_ms=env.get("EPP_PROVIDER_TIMEOUT_MS"), env=env, # Preserve raw adapter settings and the injected environment. ) \ No newline at end of file diff --git a/python/src/dispatch.py b/python/src/dispatch.py index e08913c..7b765ab 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import base64 import json import os from urllib.parse import urlsplit import requests +from azure.identity import ClientAssertionCredential, ManagedIdentityCredential from jwcrypto import jwe as jwe_module from jwcrypto import jwk from urllib3.exceptions import ReadTimeoutError @@ -241,6 +244,8 @@ def __init__(self, registry, secrets, env=None): self.registry = registry self.secrets = secrets self.env = env if env is not None else os.environ + self._oauth_credential = None + self._oauth_credential_config = None def dispatch(self, dispatch, request_id): config = read_config(self.env) @@ -257,6 +262,8 @@ def dispatch(self, dispatch, request_id): if channel not in DEFAULT_CHANNELS: return 400, {"status": "error", "provider": provider_id, "reason": "unsupported channel", "requestId": request_id} + if config.provider_channel and config.provider_channel != channel: + return 400, {"status": "error", "provider": provider_id, "reason": "channel not configured", "requestId": request_id} if channel == "voice" and manifest.get("requires_text_to_voice") and ( not isinstance(dispatch.text_to_voice, TextToVoice) or not dispatch.text_to_voice.is_complete @@ -264,13 +271,17 @@ def dispatch(self, dispatch, request_id): return 400, self._fail_body(provider_id, channel, "incomplete voice context", dispatch, request_id) auth = manifest["auth"] - if auth.get("mode") != "apiKey": - return 502, self._fail_body(provider_id, channel, "unsupported provider auth mode", dispatch, request_id) + if config.provider_auth_mode and config.provider_auth_mode != auth.get("mode"): + return 502, self._fail_body(provider_id, channel, "provider authentication mismatch", dispatch, request_id) try: - credential = self._resolve_credential(auth) + credential = self._resolve_credential(auth, config) except Exception: return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) - if not credential.get("secret") or (auth.get("identity_key_vault_secret_name") and not credential.get("identity")): + credential_unavailable = ( + credential.get("mode") == "apiKey" + and (not credential.get("secret") or (auth.get("identity_key_vault_secret_name") and not credential.get("identity"))) + ) or (credential.get("mode") == "oauth" and not credential.get("access_token")) + if credential_unavailable: return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) endpoint = config.provider_endpoint @@ -337,10 +348,40 @@ def dispatch(self, dispatch, request_id): except Exception: pass - def _resolve_credential(self, auth): - secret = self.secrets.resolve(auth.get("key_vault_secret_name")) - identity = self.secrets.resolve(auth.get("identity_key_vault_secret_name")) if auth.get("identity_key_vault_secret_name") else "" - return {"mode": "apiKey", "secret": secret, "identity": identity} + def _resolve_credential(self, auth, config): + if auth.get("mode") == "apiKey": + secret = self.secrets.resolve(auth.get("key_vault_secret_name")) + identity = self.secrets.resolve(auth.get("identity_key_vault_secret_name")) if auth.get("identity_key_vault_secret_name") else "" + return {"mode": "apiKey", "secret": secret, "identity": identity} + if auth.get("mode") != "oauth" or not all(( + config.provider_tenant_id, config.provider_scope, + config.outbound_client_id, config.outbound_managed_identity_client_id, + )): + raise ValueError("unsupported or incomplete provider authentication") + credential_config = ( + config.provider_tenant_id, + config.outbound_client_id, + config.outbound_managed_identity_client_id, + ) + if self._oauth_credential is None or self._oauth_credential_config != credential_config: + assertion_identity = ManagedIdentityCredential(client_id=config.outbound_managed_identity_client_id) + + def get_assertion(): + token = assertion_identity.get_token("api://AzureADTokenExchange/.default") + if not token or not token.token: + raise ValueError("managed identity assertion unavailable") + return token.token + + self._oauth_credential = ClientAssertionCredential( + tenant_id=config.provider_tenant_id, + client_id=config.outbound_client_id, + func=get_assertion, + ) + self._oauth_credential_config = credential_config + token = self._oauth_credential.get_token(config.provider_scope) + if not token or not token.token: + raise ValueError("provider OAuth token unavailable") + return {"mode": "oauth", "access_token": token.token} def _fail_body(self, provider, channel, reason, dispatch, request_id): return {"status": "failed", "outcome": "Fail", "provider": provider, "channel": channel, "reason": reason, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} diff --git a/python/src/models.py b/python/src/models.py index d1381d4..ee2bf14 100644 --- a/python/src/models.py +++ b/python/src/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index 4a651bc..78d483e 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -7,11 +7,7 @@ class SopranoProvider: manifest = { "id": "soprano", "requires_text_to_voice": True, - "auth": { - "mode": "apiKey", - "key_vault_secret_name": "soprano-api-key", - "identity_key_vault_secret_name": "soprano-api-id", - }, + "auth": {"mode": "oauth"}, "response_mapping": { "ENROUTE": "Continue", "ACCEPTED": "Continue", "SUBMITTED": "Continue", "SENT": "Continue", "DELIVERED": "Continue", "QUEUED": "Continue", @@ -22,8 +18,7 @@ class SopranoProvider: def build_request(self, channel, endpoint, dispatch, credential, env): message_type = "voice" if channel == "voice" else "sms" headers = { - "X-MEMS-API-ID": credential.get("identity") or "", - "X-MEMS-API-Key": credential.get("secret") or "", + "Authorization": f"Bearer {credential.get('access_token') or ''}", "Content-Type": "application/json", "Accept": "application/json", } @@ -44,7 +39,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env): }} else: body["text"] = dispatch.message - return {"url": f"{endpoint.rstrip('/')}/messages/omnimsg", "method": "POST", "headers": headers, "body": json.dumps(body)} + return {"url": endpoint, "method": "POST", "headers": headers, "body": json.dumps(body)} def parse_response(self, http_status, ok, json_body): payload = json_body[0] if isinstance(json_body, list) and json_body else json_body diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py index 57a309f..19f4098 100644 --- a/python/src/providers/telesign.py +++ b/python/src/providers/telesign.py @@ -46,7 +46,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env): "Accept": "application/json", } return { - "url": f"{endpoint.rstrip('/')}/integration/msft/cyot", + "url": endpoint, "method": "POST", "headers": headers, "body": json.dumps(body), diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index 1c2d989..e819f9b 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -20,15 +20,18 @@ def _dispatch(channel="sms"): @pytest.mark.parametrize("channel", ["sms", "voice"]) -def test_soprano_exact_sms_and_voice_contract(channel): +def test_soprano_selected_endpoint_and_oauth_contract(channel): + dispatch = _dispatch(channel) + if channel == "voice": + dispatch.text_to_voice = TextToVoice("Your code is", "001234", "en-US") request = ProviderRegistry([SopranoProvider()]).get("SOPRANO").build_request( - channel, "https://qa4.example/cgpapi///", _dispatch(channel), - {"mode": "apiKey", "identity": "test-id", "secret": "test-key"}, + channel, "https://qa4.example/oauth/messages", dispatch, + {"mode": "oauth", "access_token": "provider-token"}, {}, ) - assert request["url"] == "https://qa4.example/cgpapi/messages/omnimsg" and request["method"] == "POST" + assert request["url"] == "https://qa4.example/oauth/messages" and request["method"] == "POST" assert request["headers"] == { - "X-MEMS-API-ID": "test-id", "X-MEMS-API-Key": "test-key", + "Authorization": "Bear" + "er provider-token", "Content-Type": "application/json", "Accept": "application/json", } expected = { @@ -69,10 +72,10 @@ def test_telesign_epp_request_contract(channel, locale): dispatch = _dispatch(channel) dispatch.locale = locale request = TelesignProvider().build_request( - channel, "https://verify.telesign.com///", dispatch, + channel, f"https://verify.telesign.com/epp/{channel}", dispatch, {"mode": "apiKey", "secret": "key", "identity": "customer"}, {}, ) - assert request["method"] == "POST" and request["url"] == "https://verify.telesign.com/integration/msft/cyot" + assert request["method"] == "POST" and request["url"] == f"https://verify.telesign.com/epp/{channel}" assert request["headers"] == {"Authorization": "Basic " + base64.b64encode(b"customer:key").decode(), "Content-Type": "application/json", "Accept": "application/json"} assert json.loads(request["body"]) == { diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index f5afb09..818b8ec 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -20,19 +20,25 @@ def _request(channel="sms"): def engine(monkeypatch): registry = ProviderRegistry([SopranoProvider(), SinchProvider()]) monkeypatch.setattr(dispatch_module.requests, "request", Mock()) - return DispatchEngine(registry, Mock(resolve=Mock(return_value="test-key")), - {"EPP_PROVIDER_NAME": " SOPRANO ", "EPP_PROVIDER_ENDPOINT": "https://qa4.example/cgpapi/"}) - - -def test_missing_key_or_identity_never_sends(engine): - for missing in ("soprano-api-key", "soprano-api-id"): - engine.secrets.resolve.side_effect = lambda name: None if name == missing else "test-key" - status, body = engine.dispatch(_request(), "r") - assert status == 502 and body["reason"] == "provider credential unavailable" + result = DispatchEngine(registry, Mock(resolve=Mock(return_value="test-key")), { + "EPP_PROVIDER_NAME": " SOPRANO ", + "EPP_PROVIDER_ENDPOINT": "https://qa4.example/oauth/messages", + "EPP_PROVIDER_AUTH_MODE": "oauth", + "EPP_PROVIDER_CHANNEL": "sms", + }) + result._resolve_credential = Mock(return_value={"mode": "oauth", "access_token": "provider-token"}) + return result + + +def test_missing_oauth_configuration_never_sends(engine): + engine._resolve_credential = DispatchEngine._resolve_credential.__get__(engine, DispatchEngine) + status, body = engine.dispatch(_request(), "r") + assert status == 502 and body["reason"] == "provider credential unavailable" dispatch_module.requests.request.assert_not_called() -def test_soprano_voice_payload_uses_api_key_only(engine): +def test_soprano_voice_payload_uses_oauth(engine): + engine.env["EPP_PROVIDER_CHANNEL"] = "voice" speech = {"beforePasswordText": "Your code is", "password": "001234", "language": "en-US"} context = DeliveryContext.from_payload({"nonce": "n", "phoneNumber": "+15551234567", "message": "Your code is 001234", "textToVoice": speech}) @@ -46,9 +52,7 @@ def test_soprano_voice_payload_uses_api_key_only(engine): assert payload["voice"] == {"text2voice": speech} assert payload["messageTypes"] == ["voice"] and payload["destination"] == "15551234567" assert "text" not in payload - assert sent["headers"]["X-MEMS-API-Key"] == "test-key" - assert sent["headers"]["X-MEMS-API-ID"] == "test-key" - assert "Authorization" not in sent["headers"] + assert sent["headers"]["Authorization"] == "Bear" + "er provider-token" assert "001234" not in repr(request.text_to_voice) @@ -57,6 +61,7 @@ def test_soprano_voice_payload_uses_api_key_only(engine): {"beforePasswordText": "Code", "password": "1234", "language": " "}, {"password": "1234", "language": "en"}]) def test_incomplete_soprano_voice_never_sends(engine, speech): + engine.env["EPP_PROVIDER_CHANNEL"] = "voice" request = _request("voice") request.text_to_voice = TextToVoice.from_payload(speech) status, body = engine.dispatch(request, "r") @@ -72,6 +77,9 @@ def test_base_and_sinch_voice_final_url_guards(engine): assert status == 502 and body["reason"] == "invalid provider endpoint" engine.env["EPP_PROVIDER_ENDPOINT"] = "https://api.example" engine.env["EPP_PROVIDER_NAME"] = "sinch" + engine.env.pop("EPP_PROVIDER_CHANNEL", None) + engine.env.pop("EPP_PROVIDER_AUTH_MODE", None) + engine._resolve_credential = Mock(return_value={"mode": "apiKey", "secret": "test-key", "identity": ""}) for url in ("http://voice.example", "https://voice.example:0"): engine.env["SINCH_VOICE_ENDPOINT"] = url status, body = engine.dispatch(_request("voice"), "r") diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index fdae365..664cc54 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -34,8 +34,10 @@ def _isolate(monkeypatch): monkeypatch.setattr(function_app, "_key_provider", Mock(return_value=_PRIVATE_PEM)) engine = dispatch_module.DispatchEngine( function_app._registry, Mock(resolve=Mock(return_value="test-key")), - {"EPP_PROVIDER_NAME": "soprano", "EPP_PROVIDER_ENDPOINT": "https://qa4.example/cgpapi"}, + {"EPP_PROVIDER_NAME": "soprano", "EPP_PROVIDER_ENDPOINT": "https://qa4.example/oauth/messages", + "EPP_PROVIDER_AUTH_MODE": "oauth"}, ) + engine._resolve_credential = Mock(return_value={"mode": "oauth", "access_token": "provider-token"}) monkeypatch.setattr(function_app, "_engine", engine) monkeypatch.setattr(dispatch_module.requests, "request", Mock()) diff --git a/setup/.gitignore b/setup/.gitignore new file mode 100644 index 0000000..dff0c96 --- /dev/null +++ b/setup/.gitignore @@ -0,0 +1,7 @@ +logs/* +!logs/.gitkeep +state/* +!state/.gitkeep +policy-backups/* +!policy-backups/.gitkeep +epp-output/ diff --git a/setup/EPP-Setup.psd1 b/setup/EPP-Setup.psd1 new file mode 100644 index 0000000..3e158c4 --- /dev/null +++ b/setup/EPP-Setup.psd1 @@ -0,0 +1,14 @@ +@{ + PackageName = 'EPP endpoint deployment' + PackageVersion = '0.3.0' + EntryPoint = 'Setup-Epp.ps1' + MinimumPowerShellVersion = '7.0' + Support = @('support/Epp.Setup.psm1', 'support/Epp.Packages.ps1') + Infrastructure = @( + 'infra/main.bicep' + 'infra/resources.bicep' + ) + ProviderCatalog = 'providers/catalog.json' + PackageCatalog = 'packages/catalog.json' + RuntimeDirectories = @('epp-output') +} diff --git a/setup/Setup-Epp.ps1 b/setup/Setup-Epp.ps1 new file mode 100644 index 0000000..6a1ce86 --- /dev/null +++ b/setup/Setup-Epp.ps1 @@ -0,0 +1,87 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Download the EPP deployment tools, collect settings, and review one deployment plan. +.DESCRIPTION + Download only this file. Supporting PowerShell, Bicep, and provider JSON files come from + the selected public GitHub repository (Azure-Samples by default). + Application registration and policy activation are manual steps. + No Azure resources are changed until you approve the complete plan. +.PARAMETER SourceRepository + Public GitHub owner/repository containing the setup files. Use with SourceRef to test a fork. +.PARAMETER InstallPrerequisites + Install missing Microsoft Graph modules and the Azure CLI Bicep component after explicit opt-in. +.EXAMPLE + .\Setup-Epp.ps1 +.EXAMPLE + .\Setup-Epp.ps1 -TenantId -SubscriptionId -ApplicationId +#> +[CmdletBinding()] +param( + [string] $TenantId, + [string] $SubscriptionId, + [string] $ApplicationId, + [string] $Location, + [string] $Provider, + [string] $Channel, + [string] $EndpointRegion, + [string] $ProviderAccountName, + [string] $ResourcePrefix, + [string] $Language, + [string] $OutputDirectory = (Join-Path $PSScriptRoot 'epp-output'), + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] + [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample', + [string] $SourceRef = 'main', + [switch] $NonInteractive, + [switch] $InstallPrerequisites, + [switch] $ApproveDeployment +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = $SourceRepository +$arguments = @{} + $PSBoundParameters +$arguments.Remove('SourceRef') +$arguments.OutputDirectory = $OutputDirectory +$arguments.SourceRepository = $SourceRepository +$downloadDirectory = Join-Path ([IO.Path]::GetTempPath()) "epp-download-$([Guid]::NewGuid().ToString('N'))" +$module = $null + +try { + # Resolve once so a branch update cannot mix scripts, templates, and provider profiles. + $revision = $SourceRef + if ($revision -notmatch '^[0-9a-fA-F]{40}$') { + $commit = Invoke-RestMethod -Uri "https://api.github.com/repos/$repository/commits/$([Uri]::EscapeDataString($SourceRef))" ` + -Headers @{ 'User-Agent' = 'EPP-Setup'; Accept = 'application/vnd.github+json' } -TimeoutSec 60 + $revision = $commit.sha + } + if ($revision -notmatch '^[0-9a-fA-F]{40}$') { throw 'GitHub did not return a valid commit ID.' } + $sourceBaseUri = "https://raw.githubusercontent.com/$repository/$revision/setup" + Write-Host "Downloading deployment tools from $repository at $revision" + + foreach ($file in @('support/Epp.Setup.psm1', 'support/Epp.Packages.ps1', 'providers/catalog.json', 'packages/catalog.json', 'infra/main.bicep', 'infra/resources.bicep')) { + $destination = Join-Path $downloadDirectory $file + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + Invoke-WebRequest -Uri "$sourceBaseUri/$file" -OutFile $destination -TimeoutSec 60 -MaximumRedirection 0 + if ((Get-Item -LiteralPath $destination).Length -eq 0) { throw "GitHub returned an empty file: $file" } + } + + $module = Import-Module (Join-Path $downloadDirectory 'support/Epp.Setup.psm1') -PassThru -Force + Invoke-EppSetup @arguments -AssetDirectory $downloadDirectory -SourceBaseUri $sourceBaseUri +} +finally { + try { + if ($module) { Remove-Module -ModuleInfo $module -ErrorAction Stop } + } + catch { + Write-Warning "Could not unload the temporary EPP helper: $($_.Exception.Message)" -WarningAction Continue + } + try { + if (Test-Path -LiteralPath $downloadDirectory) { + Remove-Item -LiteralPath $downloadDirectory -Recurse -Force -ErrorAction Stop + } + } + catch { + Write-Warning "Could not remove temporary downloads at '$downloadDirectory': $($_.Exception.Message)" -WarningAction Continue + } +} diff --git a/setup/docs/README.md b/setup/docs/README.md new file mode 100644 index 0000000..a583e87 --- /dev/null +++ b/setup/docs/README.md @@ -0,0 +1,231 @@ +# EPP endpoint setup + +**Only Step 2 is scripted.** Register the customer application manually, run one downloaded +PowerShell script to deploy the endpoint, and activate policy manually after validation. + +The customer does not clone this repository or download Bicep/support scripts separately. +`Setup-Epp.ps1` retrieves those files and the selected provider's JSON from GitHub. + +## Availability + +Choose **JavaScript, .NET, or Python**, then **Telesign or Soprano**, **SMS or voice**, and a +**Global or EU endpoint**. The private test branch uses its matching fork preview release so the +package and provider-authentication contract stay in sync. There is no package URL or checksum to +enter. Setup verifies `SHA256SUMS.txt` automatically and performs the required build and publication +for the selected language. + +Provider profiles contain complete channel/region route objects. Unknown values use **explicit dummy +test values**, not a separate placeholder list or empty fields that block setup. They are written +into the Function App's **actual environment settings** after approval. Telesign's supplied route +URLs, tenant, and timings are preserved, while its zero application IDs remain test-labelled. +Soprano contains its provider tenant, production Global/EU routes, API application ID, scope, and +timing values. +The plan and saved summary identify test configuration. Deployment does not make these values +working endpoints or credentials. The provider files contain the complete deployment contract. + +The default download URLs below become usable when this change is published upstream. Before merging, +test from a published public fork using `-SourceRepository ` and +`-SourceRef `. Both options must identify the same source as the downloaded +launcher. Unpublished worktree changes are not downloadable from GitHub. + +## Step 1 - manually create the application + +Use a dedicated nonproduction tenant/subscription for the first deployment. + +1. In the customer tenant's **Microsoft Entra admin center > App registrations**, register a + dedicated organizational application. No redirect URI, client secret, API permission, app role, + or enterprise-application configuration is required manually. +2. Record the **Directory (tenant) ID** and **Application (client) ID**. The script requires the + client ID, not the application's object ID, and will not create a replacement registration. +3. Complete provider purchase, account/sender registration, and onboarding for the selected adapter. + Telesign uses `telesign-api-key` and `telesign-customer-id` in Key Vault. Soprano uses OAuth + client-assertion exchange with the selected provider tenant/scope/application ID. Setup does not + grant provider API consent or application roles. + +After the single Step 2 approval, PowerShell makes the dedicated app organizational multi-tenant, +restricts it through the Entra allowed-tenants preview to its home tenant plus the selected provider +tenant from the provider JSON, +adds the `Epp.Invoke` application permission, creates/reuses its enterprise application, requires +assignment, creates/reuses the Microsoft phone-provider service principal, and assigns `Epp.Invoke`. +It also grants that Microsoft service principal tenant-wide Microsoft Graph `Application.Read.All`, +adds the hostname-based identifier URI and public JWE encryption certificate, and configures Easy +Auth to allow only the Microsoft phone-provider application. Soprano additionally creates the +disclosed outbound managed-identity federated credential. + +## Prerequisites for Step 2 + +- **Windows with PowerShell 7+**. Certificate generation/reuse uses the current user's Windows + certificate store; this is not an Azure Cloud Shell or Linux customer deployment script. +- Azure CLI **2.48.1+** on `PATH`, with access to GitHub, Azure, Microsoft Graph, and Key Vault. + Setup installs the Azure CLI Bicep component after confirmation when it is missing. Azure CLI itself + must be installed before running the script. Python additionally needs network access to SCM. +- Microsoft Graph PowerShell modules `Microsoft.Graph.Authentication` and + `Microsoft.Graph.Applications`. Setup installs missing 2.x+ modules from PSGallery for CurrentUser + after a separate confirmation. +- An Azure **user** account permitted to deploy at subscription scope, create the listed resources, + and create the scoped Azure role assignments. +- A Microsoft Entra **Privileged Role Administrator** for granting the Microsoft first-party service + principal Graph `Application.Read.All`, plus delegated Graph scopes `Application.ReadWrite.All`, + `Application.Read.All`, and `AppRoleAssignment.ReadWrite.All`. +- Microsoft Graph **beta** access for the Entra `signInAudienceRestrictions` allowed-tenants preview. + The selected provider tenant is allowed in addition to the app's home tenant, which Entra always allows. +- **Linux Premium EP1** available in the chosen region. Setup registers missing required Azure + resource providers automatically after the single approval. The Azure account needs the + providers' subscription-scoped `/register/action` permission (included in Contributor/Owner). +- **.NET selection only:** install the .NET 8 SDK and allow NuGet access. Setup runs `dotnet publish` + automatically for `linux-x64`, packages the publish output, and deploys it. No manual build step + or upload is required. JavaScript and Python do not require this SDK. +- **Python selection:** Azure performs the Linux dependency build. No local Python, pip, or Windows + dependency installation is needed. The source archive is never used directly as run-from-package. + +| Choice | Azure runtime | Automatic deployment path | +|---|---|---| +| JavaScript | Node.js 22, Functions v4 | Verify and publish the ready ZIP with its production dependencies | +| .NET | .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, request Azure remote build, validate/download built output, publish that output | + +Package hashes are still checked; removing the **customer prompt** does not disable integrity +verification. Source and deployed-package hashes are recorded separately when a build changes the bytes. + +Setup normally detects these automatically. For unattended execution, allow installation explicitly: + +```powershell +.\Setup-Epp.ps1 -NonInteractive -InstallPrerequisites ... +``` + +Install Azure CLI through its official installation instructions if necessary. Setup checks the +explicitly supplied subscription and tenant without changing the CLI's selected subscription. If no +matching Azure user session exists, it runs `az login --tenant `. It separately requests +Graph sign-in before displaying the plan if the delegated session is missing required scopes. The +consent includes broad app-role-management scopes because the approved deployment grants +`Application.Read.All` to the Microsoft phone-provider service principal. Authentication, module +installation, Bicep installation, MFA, and consent prompts are not resource-creation approvals. + +## Step 2 - download and run one script + +Download and inspect [Setup-Epp.ps1](../Setup-Epp.ps1), or save it from the upstream raw URL: + +```powershell +Invoke-WebRequest ` + -Uri 'https://raw.githubusercontent.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/main/setup/Setup-Epp.ps1' ` + -OutFile .\Setup-Epp.ps1 +.\Setup-Epp.ps1 +``` + +The flow is: + +1. **Collect missing customer inputs:** tenant, subscription, existing application client ID, Azure + region, and provider account/sender name. Supplied values + are reused without prompts. Credentials are never requested as ordinary string parameters. +2. **Choose one language**. Setup looks up its GitHub release and checksum file in + `packages/catalog.json`; there are no `PackageUrl` or `PackageSha256` inputs. +3. **Choose a provider**, then **SMS or voice**, then **Global or EU endpoint**. Setup downloads the + provider JSON and resolves one complete route containing endpoint, authentication, app-ID/scope + when applicable, timeout, and retry interval. Explicit test values are allowed, shown as test + configuration, and passed to Azure settings. Malformed or disabled profiles still fail before + resource creation. +4. **Enter a resource prefix**, such as `contoso`: 2-8 lowercase letters/digits, starting with a + letter. Every top-level resource name then adds the meaningful `epp` marker, for example + `contoso-epp-rg-`. A deterministic suffix derived from the + subscription, application ID, and prefix reduces global-name collisions. Reruns use the same names. +5. **Check prerequisites and sign in.** Missing Graph modules or Bicep can be installed after a + separate confirmation. Azure and Graph interactive sign-in starts only when the supplied tenant + and subscription do not already have suitable user contexts. +6. **Review the complete plan**, including resource names, tenant/subscription, language, automatic + package verification/build, provider + settings, scoped roles, certificate creation, and application configuration. Bicep receives these + exact names; it does not independently calculate a different naming scheme. + The plan also lists the six required **Azure resource providers** and their registration states. + This is separate from the Telesign/Soprano provider selection. +7. **Type `Yes` once to deploy.** `No` or Enter cancels without Azure changes. Invalid answers prompt + again; individual resources do not request additional approvals. + +After approval, setup rechecks the selected subscription and registers only missing +`Microsoft.Web`, `Microsoft.Storage`, `Microsoft.KeyVault`, `Microsoft.OperationalInsights`, +`Microsoft.Insights`, and `Microsoft.ManagedIdentity` providers. Already registered providers are +left alone; existing registrations in progress are reused. Registration and regional checks happen +before certificate creation or Bicep deployment. The read-only preflight does not register anything. + +Azure registers providers region by region. Setup does not unnecessarily wait for a global +`Registered` state when a provider is already `Registering` and exposes the requested region. +Registration metadata is polled with a bounded limit, and recognized regional registration +propagation errors are retried during capability checks/deployment. Permission failures and +unsupported regions remain explicit errors. Registration is subscription-wide and isn't undone +automatically if a later deployment step fails. + +Supply known values to shorten the prompts: + +```powershell +.\Setup-Epp.ps1 ` + -TenantId ` + -SubscriptionId ` + -ApplicationId ` + -Location westus2 ` + -Language javascript ` + -Provider telesign ` + -ResourcePrefix contoso +``` + +The plan creates or updates a dedicated resource group, Linux Premium EP1 hosting plan, Function App, +storage account/private package container, Key Vault, Log Analytics workspace, Application Insights, +outbound managed identity, diagnostics, Easy Auth, and scoped role assignments. Storage/package +access uses managed identity, not account keys or SAS. Telemetry uses the system identity; the +outbound identity is selected explicitly, not through a global `AZURE_CLIENT_ID`. + +The Function starts with public ingress disabled. Setup stores the private key in Key Vault and +configures application trust. It **reads back and verifies Easy Auth before enabling ingress**. +Python requires this access for its Entra-authenticated SCM remote build; SCM basic authentication +stays disabled. Setup validates the built Python payload, stores it in private Blob storage, and +switches to managed-identity run-from-package. It never mounts the unbuilt Python source ZIP. +For every language, setup restarts, synchronizes triggers, and verifies that `SendOtp` is registered. +On publication/startup failure it disables public ingress again; failure to close ingress is reported +explicitly rather than hidden. + +The public certificate and a timestamped identifier +summary are saved to `epp-output` beside the downloaded script, or to `-OutputDirectory`. +Private keys remain in the user's certificate store and Key Vault, not in that summary. With dummy +profiles, `EPP_PROVIDER_TEST_CONFIGURATION=true` is stored alongside the real environment settings. +This is a label, not a replacement for caller authentication or a guarantee of provider connectivity. + +For unattended runs, supply every input, authenticate both clients first, and explicitly authorize +the whole displayed plan with **both** `-NonInteractive -ApproveDeployment`. `-NonInteractive` +alone never approves changes. There is no `-Stage`, `-Resume`, `-ConfigPath`, or policy-approval switch. + +### Source versioning + +`-SourceRepository` defaults to `Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample`. +The small entry point resolves `-SourceRef` (default `main`) to a single commit in that repository. All supporting +PowerShell, Bicep, the catalog, and the selected provider profile are downloaded from that commit. +Use a reviewed full commit SHA for repeatable deployments. Provider JSON selects data only; it +cannot redirect execution to another script. Download failures stop setup, and temporary downloads +are removed on completion or failure. Select only a repository whose code you trust: its supporting +PowerShell is executed locally. + +## Step 3 - manually validate and activate policy + +1. Save the Step 2 summary and confirm its tenant, application client ID, endpoint URL, encryption + key ID, and certificate with the EPP onboarding owner. **Replace all test provider values** and + provision the adapter-named API credentials in Key Vault. Verify the package's channel routing + and retry behavior; the tenant/scope metadata and test label do not enable unsupported behavior. +2. Validate the deployed endpoint with synthetic, non-delivering evaluation requests first. + Missing/invalid credentials and unauthorized callers must be rejected by Easy Auth. An admitted + caller's valid encrypted request must return the matching nonce. Then verify live SMS/voice + provider acceptance and handset delivery through the supported test procedure. Never put + phone numbers, messages, tokens, private keys, or nonce values in shared logs. +3. An **Authentication Policy Administrator**, using the approved Microsoft Graph tool and delegated + `Policy.ReadWrite.AuthenticationMethod`, must verify that the tenant's currently supported EPP + contract is available. For the preview contract formerly handled by Step 3, inspect + `https://graph.microsoft.com/beta/$metadata` for `authenticationMethodsPolicy.cyot` and its + `endpoint`, `appId`, and `migrated` fields. **If absent or different, stop and obtain the supported + onboarding procedure from Microsoft; do not send a guessed PATCH or enable a different method.** +4. Read `https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy` using that supported + contract, save the existing `cyot` value with tenant ID and timestamp, and independently approve + the migration choice. `migrated` is a routing decision, not a script default. +5. Re-read immediately before a manual change, stop if the policy changed, and use `If-Match` when + an ETag is available. Patch **only** the `cyot` property with the tested endpoint, the same + application client ID, and the deliberately chosen migration Boolean. Read it back and compare + before considering activation complete. + +Policy activation, policy backups, and policy rollback are administrator-owned manual operations. +No policy API is called by the setup package. For rollback, restore only the reviewed prior EPP +value through the still-supported contract; resource deletion is not a policy rollback. \ No newline at end of file diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md new file mode 100644 index 0000000..a51ae0b --- /dev/null +++ b/setup/docs/Troubleshooting.md @@ -0,0 +1,180 @@ +# Troubleshooting Step 2 + +## appservice list-locations rejects EP1 + +`EP1` is an Azure Functions Elastic Premium plan SKU, but older Azure CLI versions do not accept +it in the `az appservice list-locations --sku` command. The current setup uses the subscription-scoped +`Microsoft.Web/geoRegions` ARM API with `sku=ElasticPremium` and `linuxWorkersEnabled=true` instead. +Query parameters are passed in a file to avoid Windows command-shell escaping problems. +The actual deployment remains **EP1**; it is not changed to a Dedicated App Service Premium SKU. + +The accompanying 32-bit Python cryptography message is a performance warning, not the cause of +the invalid-SKU error. Rerun with the updated test-branch helper; changing the SKU or installing +another Python runtime is not required to fix this check. + +## A required Azure resource provider is not registered + +The current setup detects missing providers such as `Microsoft.Web` during read-only preflight +and lists them in the resource plan instead of asking the customer to register them manually. +After `Yes` (or explicit noninteractive approval), it registers only the six namespaces needed by +this deployment in the supplied subscription. No registration occurs if approval is declined. + +Already registered providers are skipped. `Registering` is not a failure: Azure registers each +region separately, so setup proceeds when the needed region is exposed and retries recognized +registration-propagation errors. Metadata polling is limited to 60 checks with 10-second pauses; +regional propagation retries are limited to 12 attempts. An actively `Unregistering` provider is +not reversed automatically. + +If registration fails, inspect the original Azure CLI error. The account needs subscription-scoped +resource-provider `/register/action` permission, generally included in Contributor or Owner. +Setup cannot grant this permission or bypass a subscription policy. It stops before creating the +certificate or deployment resources. Registrations already requested are left in place for a rerun; +the script does not unregister services that other workloads might now use. + +## Get-MgContext reports SessionNotInitialized + +This is different from simply not being signed in. A failed attempt to remove Graph Authentication +can run the SDK's cleanup hook and clear its internal session even though Graph Applications keeps +the module loaded. Reimporting an already loaded module normally does not initialize it again. +See the upstream [Graph SDK issue](https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/2457). + +Setup now detects this exact error during its initial context check, reloads the **same loaded +Authentication version** once, and then uses the normal sign-in flow. It does not force-remove the +SDK, upgrade modules, suppress unrelated errors, or automatically reconnect after deployment approval. +A healthy existing Graph session is reused unchanged. Noninteractive runs still require prior sign-in. + +For immediate recovery, start a new process with `pwsh -NoProfile` and rerun the downloaded script. +If initialization still fails after the one reload, setup gives this same clean-process instruction +instead of repeatedly retrying or hiding the error. + +## Remove-Module says Graph Authentication is required by Graph Applications + +Older setup versions imported the Graph SDK inside the temporary EPP module. Unloading that +helper could then attempt to remove its Graph dependencies in the wrong order, producing this +cleanup error. The current version imports both Graph modules into the PowerShell session's global +scope and unloads only its temporary EPP helper. Your Graph modules and sign-in context remain +available for subsequent commands and reruns. + +Do not add `-Force` to remove the Graph SDK. Download the updated launcher and open a fresh +PowerShell 7 window to discard module state left by the old version. Cleanup failures are now +reported as warnings, temporary-file cleanup is attempted independently, and an earlier setup +error is preserved. A cleanup error alone does not establish whether Azure deployment succeeded; +review the original output and saved deployment summary. + +## Setup still asks for PackageUrl or PackageSha256 + +You are running an older launcher or source revision. Download `Setup-Epp.ps1` again and supply +the intended `-SourceRepository` and `-SourceRef`. The current version asks for **one language** +and reads its package URL and published checksum automatically. Remove old package URL/hash +arguments from saved commands. + +## Provider settings are dummy values + +Telesign remains explicitly labelled with `deployment.testConfiguration: true` because its route +application IDs are still zero GUIDs. The selected values are written into the actual Function App +environment with `EPP_PROVIDER_TEST_CONFIGURATION=true`. Soprano now has provider-supplied tenant, +endpoint, application ID, scope, and timing values and is not labelled as test configuration. + +The script can deploy code with these values, but dummy routes cannot deliver real messages. +Update the provider-owned profile before live use. Telesign requires its API-key secrets in Key +Vault. Soprano uses the selected OAuth tenant/scope/app ID and outbound managed-identity federation; +provider consent and API roles remain external onboarding steps. + +## A checksum or package download fails + +Each language entry points to a versioned GitHub ZIP and the same release's `SHA256SUMS.txt`. +The file must contain exactly one valid entry for that asset. Missing, duplicate, malformed, or +mismatched checksums fail closed; there is no manual-hash or skip-verification workaround. +Verify the catalog's links and your access to GitHub/release assets. + +Supporting tools, Bicep, catalogs, and provider JSON all come from the commit selected at startup. +For a public-fork branch, pass both source options. A full commit SHA avoids branch-resolution +API rate limits. Private repositories are not supported by these unauthenticated raw downloads. + +## .NET build fails + +Install the **.NET 8 SDK** and allow NuGet access. Setup selects an installed 8.x SDK, extracts the +verified source into its temporary workspace, runs a Linux-targeted Release publish, checks the +publish output, and creates the ready ZIP. The source ZIP is not uploaded as runnable code. +Build failures occur before Azure resource creation and include the `dotnet` failure output. + +Do not manually replace the published source checksum with a hash of the build output. These +represent different artifacts; setup computes the built artifact's hash itself. + +## Python remote build fails + +Use Azure CLI **2.48.1+** with a user account allowed to publish to the Function App and network +access to its SCM endpoint. Setup enables `SCM_DO_BUILD_DURING_DEPLOYMENT` and `ENABLE_ORYX_BUILD`, +without `WEBSITE_RUN_FROM_PACKAGE` during the build, and requests Azure remote build explicitly. +It never installs Windows Python dependencies for the Linux app. + +SCM basic authentication remains disabled. The CLI uses Microsoft Entra authentication. The built +`site/wwwroot` snapshot must include the Python Functions dependency payload; an unbuilt source +archive is rejected even when an upload command returned success. The built output is then stored +in private Blob storage, and temporary remote-build settings are cleared. + +If build, snapshot, publication, or startup fails after opening SCM ingress, setup attempts to +disable public ingress again. An inability to close ingress is an explicit error requiring +immediate administrator inspection. Do not bypass certificate errors or enable basic auth. + +## Azure CLI warnings break JSON parsing + +The current helper separates stdout from stderr. Successful command JSON is parsed independently +of SDK warnings, while stderr warnings are shown and nonzero exit codes still fail. Upgrade an +older downloaded helper by refreshing the launcher/source revision. + +## Authentication, permission, or runtime preflight fails + +Use PowerShell 7 on Windows, Azure CLI with Bicep, and the documented Graph modules. Sign into the +customer tenant with a user account. ARM requests use the supplied subscription; setup does not +change the CLI's default subscription or adopt unrelated resource groups. + +Azure CLI itself must be installed before setup. When a matching session is absent, interactive +setup launches `az login` for the supplied tenant. Missing Graph modules and the Azure CLI Bicep +component can be installed after confirmation; noninteractive runs require +`-InstallPrerequisites` or prior installation. + +Only the dedicated customer application registration must exist from manual Step 1. After approval, +setup makes it multi-tenant, restricts it to its home tenant plus the provider JSON's `tenantId` +through the Entra allowed-tenants preview, creates both required service principals, adds and assigns +`Epp.Invoke`, and grants the Microsoft phone-provider service principal Graph `Application.Read.All`. + +Graph needs delegated `Application.ReadWrite.All`, `Application.Read.All`, and +`AppRoleAssignment.ReadWrite.All`. Granting a Microsoft Graph application permission normally +requires a Privileged Role Administrator. Noninteractive runs must authenticate both clients first +with these scopes and supply `-ApproveDeployment` separately. + +The tenant restriction uses Microsoft Graph beta `signInAudienceRestrictions`. If that preview is +unavailable or the tenant policy blocks it, setup stops before mutation rather than silently allowing +all organizational tenants. +Use a distinct resource prefix for each language; setup rejects changing a previously tagged +app to another runtime with the same prefix. + +## Deployment stops after approval + +Some resources can remain. No automatic deletion, vault purge/recovery, policy activation, or +rollback occurs. Inspect the named Azure deployment and the reported error, then rerun with the +same tenant, subscription, application, language, and prefix after correcting it. + +Recognized storage/Key Vault RBAC propagation errors are retried for at most twelve attempts. +Transient Function startup errors also have bounded retries. This includes the specific ARM +`BadRequest` response `Encountered an error (InternalServerError) from host runtime`, which Azure can +return while a newly restarted host is still loading an otherwise valid package. Generic +`InternalServerError` responses are not retried. A successful upload alone is not success: +`SendOtp` must appear in Azure's function metadata. No success summary is written if publication or +registration fails. + +If setup exhausts the retries, inspect Application Insights for host initialization, worker startup, +and function discovery errors before rerunning. The expected healthy sequence includes `Worker process +started and initialized`, `Found the following functions: Host.Functions.SendOtp`, and `Job host +started`. Setup closes public ingress after a persistent publication failure. + +## The endpoint returns 401 or live delivery fails + +Keep Easy Auth enabled. Check the trusted tenant, actual token version, audience, HTTPS requirement, +and nonempty Microsoft caller allowlist. Keep `tokenEncryptionKeyId` null on the endpoint app; +payload JWE encryption is separate from signed bearer-token validation. + +For live delivery, replace dummy endpoints and configure the provider's exact Key Vault secret +names. Test with synthetic evaluation requests before live messages. EPP policy remains a +separate, administrator-approved manual operation; no setup code updates it. diff --git a/setup/infra/main.bicep b/setup/infra/main.bicep new file mode 100644 index 0000000..12d9838 --- /dev/null +++ b/setup/infra/main.bicep @@ -0,0 +1,55 @@ +targetScope = 'subscription' + +@description('The exact resource names displayed in the approved setup plan.') +param resourceNames object + +param location string +param tenantId string +param applicationId string +param callerApplicationId string +param deployerObjectId string +param providerSettings object +param packageBlobName string +@allowed(['javascript', 'dotnet', 'python']) +param language string +param remoteBuild bool + +@allowed([1, 2]) +param tokenVersion int + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: resourceNames.resourceGroup + location: location + tags: { + managedBy: 'EPP-Setup' + eppApplicationId: applicationId + eppLanguage: language + } +} + +module endpoint 'resources.bicep' = { + name: 'epp-endpoint' + scope: resourceGroup + params: { + resourceNames: resourceNames + location: location + tenantId: tenantId + applicationId: applicationId + callerApplicationId: callerApplicationId + deployerObjectId: deployerObjectId + tokenVersion: tokenVersion + providerSettings: providerSettings + packageBlobName: packageBlobName + language: language + remoteBuild: remoteBuild + } +} + +output resourceGroupName string = resourceGroup.name +output functionAppName string = endpoint.outputs.functionAppName +output storageAccountName string = endpoint.outputs.storageAccountName +output keyVaultName string = endpoint.outputs.keyVaultName +output outboundPrincipalId string = endpoint.outputs.outboundPrincipalId +output endpointUrl string = endpoint.outputs.endpointUrl +output identifierUri string = endpoint.outputs.identifierUri +output packageContainerUrl string = endpoint.outputs.packageContainerUrl diff --git a/setup/infra/resources.bicep b/setup/infra/resources.bicep new file mode 100644 index 0000000..72dbd7c --- /dev/null +++ b/setup/infra/resources.bicep @@ -0,0 +1,335 @@ +param resourceNames object +param location string +param tenantId string +param applicationId string +param callerApplicationId string +param deployerObjectId string +param tokenVersion int +param providerSettings object +param packageBlobName string +param language string +param remoteBuild bool + +var runtimes = { + javascript: { + worker: 'node' + stack: 'NODE|22' + } + dotnet: { + worker: 'dotnet-isolated' + stack: 'DOTNET-ISOLATED|8.0' + } + python: { + worker: 'python' + stack: 'PYTHON|3.11' + } +} +var runtime = runtimes[language] + +var tags = { + managedBy: 'EPP-Setup' + eppApplicationId: applicationId + eppLanguage: language +} +var blobDataOwnerRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') +var blobDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') +var queueDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') +var tableDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') +var keyVaultSecretsUserRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') +var keyVaultSecretsOfficerRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') +var monitoringMetricsPublisherRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') + +resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: resourceNames.logAnalytics + location: location + tags: tags + properties: { + retentionInDays: 30 + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + } +} + +resource outboundIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: resourceNames.outboundIdentity + location: location + tags: tags +} + +resource insights 'Microsoft.Insights/components@2020-02-02' = { + name: resourceNames.applicationInsights + location: location + kind: 'web' + tags: tags + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspace.id + DisableLocalAuth: true + IngestionMode: 'LogAnalytics' + RetentionInDays: 30 + } +} + +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: resourceNames.storageAccount + location: location + tags: tags + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + allowBlobPublicAccess: false + allowCrossTenantReplication: false + allowSharedKeyAccess: false + defaultToOAuthAuthentication: true + minimumTlsVersion: 'TLS1_2' + publicNetworkAccess: 'Enabled' + supportsHttpsTrafficOnly: true + } +} + +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = { + parent: storage + name: 'default' +} + +resource packages 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { + parent: blobService + name: 'packages' + properties: { + publicAccess: 'None' + } +} + +resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: resourceNames.keyVault + location: location + tags: tags + properties: { + tenantId: tenantId + enableRbacAuthorization: true + enablePurgeProtection: true + enableSoftDelete: true + softDeleteRetentionInDays: 90 + publicNetworkAccess: 'Enabled' + sku: { + family: 'A' + name: 'standard' + } + } +} + +resource plan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: resourceNames.hostingPlan + location: location + kind: 'linux' + tags: tags + sku: { + name: 'EP1' + tier: 'ElasticPremium' + capacity: 1 + } + properties: { + reserved: true + maximumElasticWorkerCount: 3 + } +} + +resource functionApp 'Microsoft.Web/sites@2024-04-01' = { + name: resourceNames.functionApp + location: location + kind: 'functionapp,linux' + tags: tags + identity: { + type: 'SystemAssigned, UserAssigned' + userAssignedIdentities: { + '${outboundIdentity.id}': {} + } + } + properties: { + serverFarmId: plan.id + httpsOnly: true + // The script verifies Easy Auth before opening ingress for publication or Python remote build. + publicNetworkAccess: 'Disabled' + siteConfig: { + alwaysOn: true + minimumElasticInstanceCount: 1 + ftpsState: 'Disabled' + http20Enabled: true + linuxFxVersion: runtime.stack + minTlsVersion: '1.2' + } + } +} + +var identifierUri = 'api://${functionApp.properties.defaultHostName}/${applicationId}' +var issuer = tokenVersion == 2 ? '${environment().authentication.loginEndpoint}${tenantId}/v2.0' : 'https://sts.windows.net/${tenantId}/' +var audience = tokenVersion == 2 ? applicationId : identifierUri + +resource appSettings 'Microsoft.Web/sites/config@2024-04-01' = { + parent: functionApp + name: 'appsettings' + properties: union(providerSettings, { + FUNCTIONS_EXTENSION_VERSION: '~4' + FUNCTIONS_WORKER_RUNTIME: runtime.worker + AzureWebJobsStorage__accountName: storage.name + AzureWebJobsStorage__credential: 'managedidentity' + APPLICATIONINSIGHTS_CONNECTION_STRING: insights.properties.ConnectionString + APPLICATIONINSIGHTS_AUTHENTICATION_STRING: 'Authorization=AAD' + KEY_VAULT_URL: vault.properties.vaultUri + EPP_DECRYPTION_KEY_PEM: '@Microsoft.KeyVault(SecretUri=${vault.properties.vaultUri}secrets/phone-provider-decryption-key)' + EPP_OUTBOUND_CLIENT_ID: applicationId + EPP_OUTBOUND_MI_CLIENT_ID: outboundIdentity.properties.clientId + EPP_EXPECTED_AUDIENCE: audience + EPP_EXPECTED_ISSUER: issuer + EPP_EXPECTED_CLIENT_ID: callerApplicationId + EPP_TENANT_ID: tenantId + }, remoteBuild ? { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + } : { + WEBSITE_RUN_FROM_PACKAGE: '${storage.properties.primaryEndpoints.blob}${packages.name}/${packageBlobName}' + WEBSITE_RUN_FROM_PACKAGE_BLOB_MI_RESOURCE_ID: 'SystemAssigned' + SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' + ENABLE_ORYX_BUILD: 'false' + }) +} + +resource authentication 'Microsoft.Web/sites/config@2024-04-01' = { + parent: functionApp + name: 'authsettingsV2' + properties: { + platform: { + enabled: true + } + globalValidation: { + requireAuthentication: true + unauthenticatedClientAction: 'Return401' + excludedPaths: [] + } + httpSettings: { + requireHttps: true + } + identityProviders: { + azureActiveDirectory: { + enabled: true + registration: { + clientId: applicationId + openIdIssuer: issuer + } + validation: { + allowedAudiences: [audience] + defaultAuthorizationPolicy: { + allowedApplications: [callerApplicationId] + } + } + } + } + login: { + tokenStore: { + enabled: false + } + } + } +} + +resource systemStorageRoles 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for roleId in [ + blobDataOwnerRoleId + queueDataContributorRoleId + tableDataContributorRoleId +]: { + name: guid(storage.id, functionApp.id, roleId) + scope: storage + properties: { + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: roleId + } +}] + +resource packageUploadRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, deployerObjectId, blobDataContributorRoleId) + scope: storage + properties: { + principalId: deployerObjectId + principalType: 'User' + roleDefinitionId: blobDataContributorRoleId + } +} + +resource vaultReadRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vault.id, functionApp.id, keyVaultSecretsUserRoleId) + scope: vault + properties: { + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleId + } +} + +resource vaultWriteRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vault.id, deployerObjectId, keyVaultSecretsOfficerRoleId) + scope: vault + properties: { + principalId: deployerObjectId + principalType: 'User' + roleDefinitionId: keyVaultSecretsOfficerRoleId + } +} + +resource metricsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(insights.id, functionApp.id, monitoringMetricsPublisherRoleId) + scope: insights + properties: { + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: monitoringMetricsPublisherRoleId + } +} + +resource functionDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: 'send-to-log-analytics' + scope: functionApp + properties: { + workspaceId: workspace.id + logs: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } +} + +resource scmCredentials 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2024-04-01' = { + parent: functionApp + name: 'scm' + properties: { + allow: false + } +} + +resource ftpCredentials 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2024-04-01' = { + parent: functionApp + name: 'ftp' + properties: { + allow: false + } +} + +output functionAppName string = functionApp.name +output storageAccountName string = storage.name +output keyVaultName string = vault.name +output outboundPrincipalId string = outboundIdentity.properties.principalId +output endpointUrl string = 'https://${functionApp.properties.defaultHostName}/api/SendOtp' +output identifierUri string = identifierUri +output packageContainerUrl string = '${storage.properties.primaryEndpoints.blob}${packages.name}/' diff --git a/setup/packages/catalog.json b/setup/packages/catalog.json new file mode 100644 index 0000000..76ebe5c --- /dev/null +++ b/setup/packages/catalog.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "packages": [ + { + "id": "javascript", + "displayName": "JavaScript", + "url": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-javascript.zip", + "checksumsUrl": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/SHA256SUMS.txt", + "buildStrategy": "ready" + }, + { + "id": "dotnet", + "displayName": ".NET", + "url": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-dotnet-source.zip", + "checksumsUrl": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/SHA256SUMS.txt", + "buildStrategy": "dotnet-publish" + }, + { + "id": "python", + "displayName": "Python", + "url": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-python-source.zip", + "checksumsUrl": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/SHA256SUMS.txt", + "buildStrategy": "remote-build" + } + ] +} diff --git a/setup/providers/catalog.json b/setup/providers/catalog.json new file mode 100644 index 0000000..0521434 --- /dev/null +++ b/setup/providers/catalog.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "providers": [ + { + "id": "telesign", + "displayName": "Telesign", + "file": "telesign.json" + }, + { + "id": "soprano", + "displayName": "Soprano", + "file": "soprano.json" + } + ] +} diff --git a/setup/providers/soprano.json b/setup/providers/soprano.json new file mode 100644 index 0000000..07d94ee --- /dev/null +++ b/setup/providers/soprano.json @@ -0,0 +1,45 @@ +{ + "deployment": { + "enabled": true, + "testConfiguration": false, + "providerName": "Soprano", + "tenantId": "801bae25-4443-4a29-9e56-9d1cf22ff819", + "authentication": { + "mode": "oauth" + }, + "routes": { + "sms": { + "global": { + "endpoint": "https://na1.smartmessagingsuite.com/cgpapi/messages/sendmsg/", + "appId": "32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe", + "scope": "api://32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://eu.sopranodesign.com/cgpapi/sendmsg", + "appId": "32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe", + "scope": "api://32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + }, + "voice": { + "global": { + "endpoint": "https://na1.smartmessagingsuite.com/cgpapi/messages/sendmsg/", + "appId": "32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe", + "scope": "api://32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://eu.sopranodesign.com/cgpapi/messages/sendmsg", + "appId": "32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe", + "scope": "api://32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + } + } + } +} diff --git a/setup/providers/telesign.json b/setup/providers/telesign.json new file mode 100644 index 0000000..ab47f65 --- /dev/null +++ b/setup/providers/telesign.json @@ -0,0 +1,44 @@ +{ + "deployment": { + "enabled": true, + "testConfiguration": true, + "providerName": "Telesign", + "tenantId": "d818b557-ea1c-4070-a3f1-928330b7a30c", + "authentication": { + "mode": "apiKey", + "keyVaultSecretName": "telesign-api-key", + "identityKeyVaultSecretName": "telesign-customer-id" + }, + "routes": { + "sms": { + "global": { + "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/sms", + "appId": "00000000-0000-0000-0000-000000000000", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/sms", + "appId": "00000000-0000-0000-0000-000000000000", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + }, + "voice": { + "global": { + "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/voice", + "appId": "00000000-0000-0000-0000-000000000000", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/voice", + "appId": "00000000-0000-0000-0000-000000000000", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + } + }, + "note": "The supplied global SMS and voice URLs are preserved. EU URLs and application IDs are explicit test values until Telesign provides them." + } +} diff --git a/setup/support/Epp.Packages.ps1 b/setup/support/Epp.Packages.ps1 new file mode 100644 index 0000000..c4fc307 --- /dev/null +++ b/setup/support/Epp.Packages.ps1 @@ -0,0 +1,156 @@ +function Get-EppLanguage { + param([string] $AssetDirectory, [string] $Language, [string] $SourceRepository, [switch] $NonInteractive) + + $catalog = Read-EppJson (Join-Path $AssetDirectory 'packages/catalog.json') + if ($catalog['schemaVersion'] -ne 1 -or -not $catalog['packages']) { throw 'Unsupported or empty language package catalog.' } + $strategies = @{ javascript = 'ready'; dotnet = 'dotnet-publish'; python = 'remote-build' } + $seen = @{} + $entries = @($catalog['packages']) + foreach ($entry in $entries) { + if ($entry -isnot [Collections.IDictionary] -or -not $strategies.ContainsKey([string]$entry['id']) -or + $seen.ContainsKey($entry['id']) -or $entry['buildStrategy'] -cne $strategies[$entry['id']] -or + -not $entry['displayName'] -or $entry['displayName'] -match '[\x00-\x1f]') { + throw 'Language catalog contains an invalid, unsupported, or duplicate entry.' + } + $seen[$entry['id']] = $true + $null = Read-EppInput -Name PackageUrl -Value $entry['url'] -Kind PackageUrl -SourceRepository $SourceRepository -NonInteractive + $url = [Uri]$entry['url'] + if ($url.Segments[-1] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*\.zip$' -or + $entry['checksumsUrl'] -cne ($entry['url'].Substring(0, $entry['url'].LastIndexOf('/') + 1) + 'SHA256SUMS.txt')) { + throw 'Each package needs an unambiguous ZIP filename and SHA256SUMS.txt in the same GitHub release.' + } + } + $entry = Select-EppOption -Entries $entries -Name Language -Value $Language -NonInteractive:$NonInteractive + return [pscustomobject]@{ + Id = $entry['id']; DisplayName = $entry['displayName']; Url = $entry['url']; ChecksumsUrl = $entry['checksumsUrl'] + BuildStrategy = $entry['buildStrategy'] + } +} + +function Get-EppPublishedChecksum { + param([string] $Text, [string] $FileName) + + $matches = @() + foreach ($line in ($Text.TrimStart([char]0xfeff) -split '\r?\n')) { + $match = [regex]::Match($line, '^([0-9a-fA-F]{64})[ \t]+\*?(.+?)[ \t]*$') + if ($match.Success -and $match.Groups[2].Value -ceq $FileName) { + $matches += $match.Groups[1].Value.ToLowerInvariant() + } + } + if ($matches.Count -ne 1) { throw "The release checksum file must contain exactly one SHA-256 entry for '$FileName'." } + return $matches[0] +} + +function Assert-EppArchive { + param( + [string] $Path, + [ValidateSet('javascript', 'dotnet', 'python')][string] $Language, + [ValidateSet('source', 'ready')][string] $Kind + ) + + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $names = @($archive.Entries | ForEach-Object FullName) + $required = @('host.json') + switch ($Language) { + 'javascript' { $required += 'package.json' } + 'dotnet' { + if ($Kind -eq 'source') { $required += 'dotnet.csproj' } + else { $required += @('worker.config.json', 'functions.metadata') } + } + 'python' { + $required += @('function_app.py', 'requirements.txt') + if ($Kind -eq 'ready') { $required += '.python_packages/lib/site-packages/azure/functions/__init__.py' } + } + } + foreach ($file in $required) { + if (@($names | Where-Object { $_ -ceq $file }).Count -ne 1) { + throw "The $Language $Kind ZIP must contain exactly one '$file' at its required deployment path." + } + } + if ($Language -eq 'dotnet' -and $Kind -eq 'ready' -and -not @($names | Where-Object { $_ -cmatch '^[^/]+\.dll$' }).Count) { + throw 'The .NET publish output contains no application assemblies.' + } + if (@($names | Where-Object { $_ -match '\\|(^|/)\.\.?(/|$)|^/|^[a-zA-Z]:' }).Count) { + throw 'The Function ZIP contains an absolute or traversing archive path.' + } + foreach ($entry in $archive.Entries) { + if ($entry.FullName -match '(?i)(^|/)(local\.settings[^/]*\.json|\.env(?:\.[^/]*)?|[^/]+\.(pfx|p12|pem|key))$') { + # Python's certifi dependency ships public CA roots, not an application private key. + if ($Language -eq 'python' -and $Kind -eq 'ready' -and + $entry.FullName -ceq '.python_packages/lib/site-packages/certifi/cacert.pem') { + $reader = [IO.StreamReader]::new($entry.Open()) + try { + if ($reader.ReadToEnd() -match '-----BEGIN [^-]*PRIVATE KEY-----') { throw 'The CA bundle contains a private key.' } + } + finally { $reader.Dispose() } + continue + } + throw 'The Function ZIP contains local settings or key material. Do not deploy this package.' + } + } + } + finally { $archive.Dispose() } +} + +function Invoke-EppDotNet { + param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) + + $PSNativeCommandUseErrorActionPreference = $false + $output = & dotnet @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { throw "dotnet $($Arguments[0]) failed (exit $LASTEXITCODE):`n$($output -join "`n")" } + return $output -join "`n" +} + +function Build-EppDotNetPackage { + param([string] $SourcePath, [string] $Directory) + + if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + throw 'The .NET language needs the .NET 8 SDK on this computer. Install it once; setup performs the build automatically.' + } + $versions = @((Invoke-EppDotNet --list-sdks) -split '\r?\n' | ForEach-Object { + if ($_ -match '^(8\.\d+\.\d+)\s') { [Version]$Matches[1] } + } | Sort-Object -Descending) + if (-not $versions.Count) { throw 'Install the .NET 8 SDK before deploying the .NET language. No Azure resources were changed.' } + $sourceDirectory = Join-Path $Directory 'dotnet-source' + $publishDirectory = Join-Path $Directory 'dotnet-publish' + [IO.Compression.ZipFile]::ExtractToDirectory($SourcePath, $sourceDirectory) + @{ sdk = @{ version = $versions[0].ToString(); rollForward = 'latestPatch' } } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $sourceDirectory 'global.json') -Encoding utf8NoBOM + Write-Host 'Building .NET 8 for Linux automatically...' -ForegroundColor Cyan + Push-Location -LiteralPath $sourceDirectory + try { + Invoke-EppDotNet -Arguments @('publish', 'dotnet.csproj', '--configuration', 'Release', '--runtime', 'linux-x64', + '--self-contained', 'false', '-p:UseAppHost=false', '--output', $publishDirectory, '--nologo') | Out-Null + } + finally { Pop-Location } + $path = Join-Path $Directory 'dotnet-ready.zip' + [IO.Compression.ZipFile]::CreateFromDirectory($publishDirectory, $path) + Assert-EppArchive -Path $path -Language dotnet -Kind ready + return $path +} + +function Get-EppPackage { + param($Selection, [string] $Directory) + + Write-Host "Downloading $($Selection.DisplayName) and verifying its published checksum automatically..." -ForegroundColor Cyan + $checksumPath = Join-Path $Directory "$($Selection.Id)-SHA256SUMS.txt" + Invoke-WebRequest -Uri $Selection.ChecksumsUrl -OutFile $checksumPath -TimeoutSec 60 + if ((Get-Item -LiteralPath $checksumPath).Length -gt 1MB) { throw 'The release checksum file is unexpectedly large.' } + $fileName = ([Uri]$Selection.Url).Segments[-1] + $expected = Get-EppPublishedChecksum -Text (Get-Content -LiteralPath $checksumPath -Raw -Encoding utf8) -FileName $fileName + $sourcePath = Join-Path $Directory $fileName + Invoke-WebRequest -Uri $Selection.Url -OutFile $sourcePath -TimeoutSec 300 + if ((Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash -ine $expected) { + throw 'The downloaded Function ZIP does not match its published SHA-256. No Azure resources were changed.' + } + $kind = if ($Selection.BuildStrategy -eq 'ready') { 'ready' } else { 'source' } + Assert-EppArchive -Path $sourcePath -Language $Selection.Id -Kind $kind + $path = if ($Selection.BuildStrategy -eq 'dotnet-publish') { Build-EppDotNetPackage -SourcePath $sourcePath -Directory $Directory } else { $sourcePath } + return [pscustomobject]@{ + Path = $path + SourceSha256 = $expected + Sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + RequiresRemoteBuild = $Selection.BuildStrategy -eq 'remote-build' + } +} diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1 new file mode 100644 index 0000000..652c344 --- /dev/null +++ b/setup/support/Epp.Setup.psm1 @@ -0,0 +1,1418 @@ +#Requires -Version 7.0 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$script:MicrosoftPhoneProviderAppId = '25ec60fa-f18d-41a4-b398-50044c90ce13' +$script:MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000' +$script:MicrosoftGraphApplicationReadAllRoleId = '9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30' +$script:EppInvokeAppRoleId = 'ddf32018-9212-41c7-b73c-f5dfe73a2f24' +$script:EppInvokeAppRoleValue = 'Epp.Invoke' +$script:GraphRequiredScopes = @('Application.ReadWrite.All', 'Application.Read.All', 'AppRoleAssignment.ReadWrite.All') +. (Join-Path $PSScriptRoot 'Epp.Packages.ps1') + +function Read-EppJson { + param([string] $Path) + + $value = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop + if ($value -isnot [Collections.IDictionary]) { throw "Expected a JSON object in '$Path'." } + return $value +} + +function ConvertTo-EppGuid { + param([string] $Value, [switch] $AllowZero) + + $guid = [Guid]::Empty + if (-not [Guid]::TryParse($Value, [ref] $guid) -or (-not $AllowZero -and $guid -eq [Guid]::Empty)) { + throw 'Use a nonempty GUID, not an application name or an all-zero placeholder.' + } + return $guid.ToString('D') +} + +function Assert-EppHttpsUrl { + param([string] $Value, [switch] $AllowTestHost) + + $uri = $null + if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref] $uri) -or + $uri.Scheme -ne 'https' -or $uri.Port -ne 443 -or $uri.IsLoopback -or + $uri.HostNameType -ne [UriHostNameType]::Dns -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or + $uri.Host -notmatch '\.' -or + (-not $AllowTestHost -and $uri.Host -match '(?i)((^|\.)example\.(com|net|org)$|\.(invalid|test|example)$)')) { + throw 'Use a public HTTPS hostname on port 443, without credentials, a query string, or placeholders.' + } +} + +function Select-EppOption { + param([object[]] $Entries, [string] $Name, [string] $Value, [switch] $NonInteractive) + + $ids = @($Entries | ForEach-Object { $_['id'] }) + if ($Value) { + $selected = $Entries | Where-Object { $_['id'] -ieq $Value -or $_['displayName'] -ieq $Value } | Select-Object -First 1 + if (-not $selected) { throw "Unknown $Name '$Value'. Choose: $($ids -join ', ')." } + return $selected + } + if ($NonInteractive) { throw "-$Name is required. Choose: $($ids -join ', ')." } + Write-Host "`nChoose your $($Name.ToLowerInvariant()):" -ForegroundColor Cyan + for ($index = 0; $index -lt $Entries.Count; $index++) { Write-Host " [$($index + 1)] $($Entries[$index]['displayName'])" } + while ($true) { + $answer = ([string](Read-Host "$Name number or name")).Trim() + $number = 0 + if ([int]::TryParse($answer, [ref] $number) -and $number -ge 1 -and $number -le $Entries.Count) { return $Entries[$number - 1] } + $selected = $Entries | Where-Object { $_['id'] -ieq $answer -or $_['displayName'] -ieq $answer } | Select-Object -First 1 + if ($selected) { return $selected } + Write-Warning "Choose one of the listed $($Name.ToLowerInvariant()) options." + } +} + +function Read-EppInput { + param( + [string] $Name, [string] $Value, [string] $Hint, + [ValidateSet('Text', 'Guid', 'Location', 'Prefix', 'PackageUrl', 'Hash')] + [string] $Kind = 'Text', + [switch] $NonInteractive, + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] + [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample' + ) + + $supplied = -not [string]::IsNullOrWhiteSpace($Value) + while ($true) { + if (-not $supplied) { + if ($NonInteractive) { throw "-$Name is required in noninteractive mode." } + $Value = [string](Read-Host "$Name - $Hint") + } + $Value = $Value.Trim() + try { + if (-not $Value -or $Value -match '[\x00-\x1f<>]') { throw 'A nonempty value without placeholders is required.' } + switch ($Kind) { + 'Guid' { $Value = ConvertTo-EppGuid $Value } + 'Location' { + if ($Value -cnotmatch '^[a-z][a-z0-9]+$') { throw 'Use an Azure region name such as westus2.' } + } + 'Prefix' { + if ($Value -cnotmatch '^[a-z][a-z0-9]{1,7}$') { + throw 'Use 2-8 lowercase letters or digits, starting with a letter (for example contoso).' + } + } + 'PackageUrl' { + Assert-EppHttpsUrl $Value + $repositories = @('Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample', $SourceRepository) + $allowed = @($repositories | Where-Object { + $Value -cmatch ('^https://github\.com/' + [regex]::Escape($_) + '/releases/download/[^/]+/[^/]+\.zip$') + }) + if (-not $allowed.Count) { + throw 'Use a versioned ZIP release URL from the selected source repository or Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample.' + } + } + 'Hash' { + if ($Value -notmatch '^[0-9a-fA-F]{64}$') { throw 'Use the package SHA-256 from its release checksums.' } + $Value = $Value.ToLowerInvariant() + } + } + return $Value + } + catch { + if ($supplied) { throw "Invalid -${Name}: $($_.Exception.Message)" } + Write-Warning "$Name : $($_.Exception.Message)" + } + } +} + +function Get-EppProvider { + param( + [string] $AssetDirectory, [string] $SourceBaseUri, [string] $Provider, [string] $Channel, + [string] $EndpointRegion, [switch] $NonInteractive, + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] + [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample' + ) + + $sourcePattern = '^https://raw\.githubusercontent\.com/' + [regex]::Escape($SourceRepository) + '/[0-9a-fA-F]{40}/setup$' + if ($SourceBaseUri -cnotmatch $sourcePattern) { + throw 'Provider files must come from the same commit-pinned selected repository as the deployment tools.' + } + $catalog = Read-EppJson (Join-Path $AssetDirectory 'providers/catalog.json') + if ($catalog['schemaVersion'] -ne 1 -or -not $catalog['providers']) { throw 'Unsupported or empty provider catalog.' } + $entries = @($catalog['providers']) + $ids = @{} + foreach ($entry in $entries) { + if ($entry -isnot [Collections.IDictionary] -or $entry['id'] -cnotmatch '^[a-z][a-z0-9-]{1,31}$' -or + $entry['file'] -cnotmatch '^[a-z][a-z0-9-]{1,31}\.json$' -or + -not $entry['displayName'] -or $entry['displayName'] -match '[\x00-\x1f]' -or $ids.ContainsKey($entry['id'])) { + throw 'Provider catalog contains an invalid or duplicate entry.' + } + $ids[$entry['id']] = $true + } + $selected = Select-EppOption -Entries $entries -Name Provider -Value $Provider -NonInteractive:$NonInteractive + + $path = Join-Path $AssetDirectory "providers/$($selected['file'])" + Invoke-WebRequest -Uri "$SourceBaseUri/providers/$($selected['file'])" -OutFile $path -TimeoutSec 60 -MaximumRedirection 0 + $profile = Read-EppJson $path + return ConvertTo-EppProviderSettings -Profile $profile -Id $selected['id'] -DisplayName $selected['displayName'] ` + -Channel $Channel -EndpointRegion $EndpointRegion -NonInteractive:$NonInteractive +} + +function ConvertTo-EppProviderSettings { + param( + [Collections.IDictionary] $Profile, [string] $Id, [string] $DisplayName, + [string] $Channel, [string] $EndpointRegion, [switch] $NonInteractive + ) + + $issues = [Collections.Generic.List[string]]::new() + $deployment = $Profile['deployment'] + if ($deployment -isnot [Collections.IDictionary]) { throw "Provider '$DisplayName' has no deployment configuration." } + $testConfiguration = $deployment['testConfiguration'] -eq $true + if ($deployment.Contains('testConfiguration') -and $deployment['testConfiguration'] -isnot [bool]) { + $issues.Add('deployment.testConfiguration must be a JSON Boolean') + } + if ($deployment['enabled'] -isnot [bool] -or -not $deployment['enabled']) { + $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') } + try { $providerTenantId = ConvertTo-EppGuid $deployment['tenantId'] -AllowZero:$testConfiguration } + catch { $issues.Add('deployment.tenantId must identify the provider tenant') } + + $authentication = $deployment['authentication'] + if ($authentication -isnot [Collections.IDictionary] -or $authentication['mode'] -notin @('apiKey', 'oauth')) { + $issues.Add('deployment.authentication.mode must be apiKey or oauth') + } + $authenticationMode = if ($authentication -is [Collections.IDictionary]) { [string]$authentication['mode'] } else { '' } + if ($authenticationMode -eq 'apiKey') { + foreach ($name in @('keyVaultSecretName', 'identityKeyVaultSecretName')) { + if ($authentication[$name] -cnotmatch '^[a-z0-9][a-z0-9-]{1,126}$') { + $issues.Add("deployment.authentication.$name must be a Key Vault secret name") + } + } + } + + $routes = $deployment['routes'] + if ($routes -isnot [Collections.IDictionary]) { throw "Provider '$DisplayName' is missing deployment.routes." } + foreach ($channelId in @('sms', 'voice')) { + if ($routes[$channelId] -isnot [Collections.IDictionary]) { + $issues.Add("deployment.routes.$channelId is missing") + continue + } + foreach ($regionId in @('global', 'eu')) { + $route = $routes[$channelId][$regionId] + if ($route -isnot [Collections.IDictionary]) { + $issues.Add("deployment.routes.$channelId.$regionId is missing") + continue + } + try { Assert-EppHttpsUrl $route['endpoint'] -AllowTestHost:$testConfiguration } + catch { $issues.Add("deployment.routes.$channelId.$regionId.endpoint must be a public HTTPS endpoint") } + $timeout = $route['timeoutMilliseconds'] + $retry = $route['retryIntervalSeconds'] + if (($timeout -isnot [long] -and $timeout -isnot [int]) -or $timeout -lt 1 -or $timeout -gt 2500) { + $issues.Add("deployment.routes.$channelId.$regionId.timeoutMilliseconds must be an integer from 1 to 2500") + } + if (($retry -isnot [long] -and $retry -isnot [int]) -or $retry -lt 0 -or $retry -gt 2147483) { + $issues.Add("deployment.routes.$channelId.$regionId.retryIntervalSeconds must be a nonnegative integer fitting Int32 milliseconds") + } + if ($authenticationMode -eq 'oauth') { + try { $null = ConvertTo-EppGuid $route['appId'] -AllowZero:$testConfiguration } + catch { $issues.Add("deployment.routes.$channelId.$regionId.appId must identify the provider API application") } + $scope = [string]$route['scope'] + $resource = $scope -replace '/\.default$', '' + $resourceUri = $null + $resourceGuid = [Guid]::Empty + $validResource = ([Guid]::TryParse($resource, [ref] $resourceGuid) -and ($testConfiguration -or $resourceGuid -ne [Guid]::Empty)) -or + ([Uri]::TryCreate($resource, [UriKind]::Absolute, [ref] $resourceUri) -and + $resourceUri.Scheme -in @('api', 'https') -and $resourceUri.Host -and + -not $resourceUri.UserInfo -and -not $resourceUri.Query -and -not $resourceUri.Fragment) + if (-not $validResource -or $scope -notmatch '/\.default$' -or $scope -match '[\s<>]') { + $issues.Add("deployment.routes.$channelId.$regionId.scope must be the provider API resource followed by /.default") + } + } + } + } + if ($issues.Count) { + throw "Provider '$DisplayName' is not deployment-ready:`n - $($issues -join "`n - ")`nAsk the provider owner to complete its GitHub JSON. No Azure resources were changed." + } + + $channelEntry = Select-EppOption -Entries @( + @{ id = 'sms'; displayName = 'SMS' } + @{ id = 'voice'; displayName = 'Voice' } + ) -Name Channel -Value $Channel -NonInteractive:$NonInteractive + $regionEntry = Select-EppOption -Entries @( + @{ id = 'global'; displayName = 'Global endpoint' } + @{ id = 'eu'; displayName = 'EU endpoint' } + ) -Name EndpointRegion -Value $EndpointRegion -NonInteractive:$NonInteractive + $selectedRoute = $routes[$channelEntry['id']][$regionEntry['id']] + $settings = @{ + EPP_PROVIDER_NAME = $Id + EPP_PROVIDER_ENDPOINT = [string]$selectedRoute['endpoint'] + EPP_PROVIDER_CHANNEL = [string]$channelEntry['id'] + EPP_PROVIDER_ENDPOINT_REGION = [string]$regionEntry['id'] + 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_TEST_CONFIGURATION = $testConfiguration.ToString().ToLowerInvariant() + } + if ($authenticationMode -eq 'oauth') { + $settings.EPP_PROVIDER_SCOPE = [string]$selectedRoute['scope'] + $settings.EPP_PROVIDER_APP_ID = [string]$selectedRoute['appId'] + } + return [pscustomobject]@{ + Id = $Id + DisplayName = $DisplayName + Manifest = $Profile + IsTestConfiguration = $testConfiguration + Channel = [string]$channelEntry['id'] + EndpointRegion = [string]$regionEntry['id'] + AuthenticationMode = $authenticationMode + Settings = $settings + } +} + +function Get-EppResourceNames { + param([string] $SubscriptionId, [string] $ApplicationId, [string] $ResourcePrefix) + + if ($ResourcePrefix -cnotmatch '^[a-z][a-z0-9]{1,7}$') { throw 'ResourcePrefix must be 2-8 lowercase letters/digits, starting with a letter.' } + $seed = "$(ConvertTo-EppGuid $SubscriptionId)|$(ConvertTo-EppGuid $ApplicationId)|$ResourcePrefix" + $sha = [Security.Cryptography.SHA256]::Create() + try { $suffix = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($seed))) -replace '-', '').Substring(0, 8).ToLowerInvariant() } + finally { $sha.Dispose() } + return [ordered]@{ + resourceGroup = "$ResourcePrefix-epp-rg-$suffix" + functionApp = "$ResourcePrefix-epp-func-$suffix" + storageAccount = "${ResourcePrefix}eppsa$suffix" + keyVault = "$ResourcePrefix-epp-kv-$suffix" + hostingPlan = "$ResourcePrefix-epp-plan-$suffix" + logAnalytics = "$ResourcePrefix-epp-logs-$suffix" + applicationInsights = "$ResourcePrefix-epp-insights-$suffix" + outboundIdentity = "$ResourcePrefix-epp-outbound-$suffix" + } +} + +function Invoke-EppAz { + param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) + + $PSNativeCommandUseErrorActionPreference = $false + $errorPath = Join-Path ([IO.Path]::GetTempPath()) "epp-az-$([Guid]::NewGuid().ToString('N')).stderr" + try { + $output = & az @Arguments --only-show-errors 2> $errorPath + $exitCode = $LASTEXITCODE + $errorText = if (Test-Path -LiteralPath $errorPath) { [string](Get-Content -LiteralPath $errorPath -Raw) } else { '' } + $message = (($output -join "`n") + "`n" + $errorText) -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]' + $message = $message -replace '(?i)(Bearer\s+)[^\s,;]+', '$1[REDACTED]' + if ($exitCode -ne 0) { + throw "Azure CLI operation '$($Arguments[0]) $($Arguments[1])' failed (exit $exitCode): $message" + } + if (-not [string]::IsNullOrWhiteSpace($errorText)) { + $warning = $errorText -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]' + Write-Warning ($warning -replace '(?i)(Bearer\s+)[^\s,;]+', '$1[REDACTED]') + } + return $output -join "`n" + } + finally { if (Test-Path -LiteralPath $errorPath) { Remove-Item -LiteralPath $errorPath -Force } } +} + +function Invoke-EppDataOperation { + param([scriptblock] $Operation) + + for ($attempt = 1; $attempt -le 12; $attempt++) { + try { return & $Operation } + catch { + if ($attempt -eq 12 -or $_.Exception.Message -notmatch 'ForbiddenByRbac|AuthorizationPermissionMismatch|Caller is not authorized to perform action on resource') { throw } + Write-Warning "Waiting for the new data-plane role assignment ($attempt/12)." + Start-Sleep -Seconds 10 + } + } +} + +function Import-EppGraphModules { + param([switch] $NonInteractive, [switch] $InstallPrerequisites) + + $requiredModules = @('Microsoft.Graph.Authentication', 'Microsoft.Graph.Applications') + $missing = @($requiredModules | Where-Object { + $available = @(Get-Module -ListAvailable -Name $_) + -not @($available | Where-Object Version -ge ([Version]'2.0.0')).Count + }) + if ($missing.Count) { + if (-not $InstallPrerequisites) { + if ($NonInteractive) { + throw "Missing Microsoft Graph modules: $($missing -join ', '). Rerun with -InstallPrerequisites or install them for CurrentUser." + } + while ($true) { + $answer = ([string](Read-Host "Install missing Microsoft Graph modules from PSGallery for CurrentUser ($($missing -join ', '))? Type Yes or No [No]")).Trim() + if ($answer -ieq 'Yes') { break } + if (-not $answer -or $answer -ieq 'No') { + throw "Install the missing Microsoft Graph modules and rerun setup. No Azure or tenant resources were changed." + } + Write-Warning 'Type Yes to install the listed modules, or No/Enter to stop.' + } + } + if (-not (Get-Command Install-Module -ErrorAction SilentlyContinue)) { + throw 'Install-Module is unavailable. Install PowerShellGet, then install the required Microsoft Graph modules.' + } + foreach ($name in $missing) { + Write-Host "Installing $name from PSGallery for CurrentUser..." -ForegroundColor Cyan + Install-Module -Name $name -Scope CurrentUser -Repository PSGallery -MinimumVersion 2.0.0 ` + -Force -AllowClobber -ErrorAction Stop + } + } + + # The SDK and its sign-in context belong to the session, not this temporary helper module. + foreach ($name in $requiredModules) { + $module = @(Get-Module -ListAvailable -Name $name) | Where-Object Version -ge ([Version]'2.0.0') | + Sort-Object Version -Descending | Select-Object -First 1 + if (-not $module) { throw "Microsoft Graph module '$name' was not available after prerequisite setup." } + $loaded = Get-Module -Name $name | Select-Object -First 1 + Import-Module $module.Path -Global -Force:($loaded -and $loaded.Version -ne $module.Version) -ErrorAction Stop + } + foreach ($command in @( + 'Connect-MgGraph', 'Invoke-MgGraphRequest', 'Get-MgApplication', 'Update-MgApplication', + 'Get-MgServicePrincipal', 'New-MgServicePrincipal', 'Update-MgServicePrincipal', + 'Get-MgServicePrincipalAppRoleAssignment', 'New-MgServicePrincipalAppRoleAssignment' + )) { + if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { + throw "The installed Microsoft Graph modules do not provide required command '$command'. Update both Graph modules and rerun." + } + } +} + +function Confirm-EppPrerequisiteInstall { + param([string] $Description, [switch] $NonInteractive, [switch] $InstallPrerequisites) + + if ($InstallPrerequisites) { return } + if ($NonInteractive) { throw "$Description is missing. Rerun with -InstallPrerequisites or install it manually." } + while ($true) { + $answer = ([string](Read-Host "Install $Description now? Type Yes or No [No]")).Trim() + if ($answer -ieq 'Yes') { return } + if (-not $answer -or $answer -ieq 'No') { throw "$Description is required. No Azure or tenant resources were changed." } + Write-Warning 'Type Yes to install the prerequisite, or No/Enter to stop.' + } +} + +function Initialize-EppBicep { + param([switch] $NonInteractive, [switch] $InstallPrerequisites) + + try { + Invoke-EppAz bicep version --output none | Out-Null + return + } + catch { + if ($_.Exception.Message -notmatch 'Bicep.*(?:not found|not installed)|az bicep install') { throw } + } + Confirm-EppPrerequisiteInstall -Description 'the Azure CLI Bicep component' ` + -NonInteractive:$NonInteractive -InstallPrerequisites:$InstallPrerequisites + Write-Host 'Installing the Azure CLI Bicep component...' -ForegroundColor Cyan + Invoke-EppAz bicep install --output none | Out-Null + Invoke-EppAz bicep version --output none | Out-Null +} + +function Connect-EppAzureAccount { + param([hashtable] $Inputs, [switch] $NonInteractive) + + $account = $null + try { + $account = Invoke-EppAz account show --subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json + } + catch { + if ($NonInteractive) { + throw "Azure CLI is not signed in to subscription '$($Inputs.SubscriptionId)' in tenant '$($Inputs.TenantId)'. Run az login first." + } + Write-Host "Signing in to Azure tenant $($Inputs.TenantId)..." -ForegroundColor Cyan + Invoke-EppAz login --tenant $Inputs.TenantId --output none | Out-Null + $account = Invoke-EppAz account show --subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json + } + if ($account.id -ne $Inputs.SubscriptionId -or $account.tenantId -ne $Inputs.TenantId -or + $account.state -ne 'Enabled' -or $account.environmentName -ne 'AzureCloud' -or $account.user.type -ne 'user') { + throw 'Azure CLI must be signed in as a user to the requested enabled subscription and tenant in the public Azure cloud.' + } + return $account +} + +function Get-EppInitialGraphContext { + try { return Get-MgContext -ErrorAction Stop } + catch { + if ($_.Exception.GetBaseException().Message -cne 'SessionNotInitialized') { throw } + } + + # Graph's failed OnRemove hook can reset its static session while leaving the module loaded. + $authentication = @(Get-Module -Name Microsoft.Graph.Authentication) + if ($authentication.Count -ne 1) { + throw 'The Graph SDK session is uninitialized and its loaded Authentication version is ambiguous. Run setup in a fresh PowerShell process with pwsh -NoProfile.' + } + Write-Warning 'An earlier module removal reset the Graph SDK session. Reloading its existing Authentication version once; sign-in may be required.' + Import-Module Microsoft.Graph.Authentication -RequiredVersion $authentication[0].Version -Global -Force -ErrorAction Stop + try { return Get-MgContext -ErrorAction Stop } + catch { + if ($_.Exception.GetBaseException().Message -cne 'SessionNotInitialized') { throw } + throw 'The Graph SDK session could not be reinitialized. Run setup in a fresh PowerShell process with pwsh -NoProfile; no Azure resources were changed.' + } +} + +function Test-EppGraphContext { + param($Context, [string] $TenantId) + + if (-not $Context -or $Context.TenantId -ne $TenantId -or $Context.Environment -ne 'Global' -or + $Context.AuthType -ne 'Delegated') { + return $false + } + return @($script:GraphRequiredScopes | Where-Object { $Context.Scopes -notcontains $_ }).Count -eq 0 +} + +function Get-EppSignInAudienceRestrictions { + param([string] $ApplicationObjectId) + + $result = Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/applications/$ApplicationObjectId`?`$select=id,signInAudience,signInAudienceRestrictions" ` + -OutputType PSObject -ErrorAction Stop + return $result.signInAudienceRestrictions +} + +function Test-EppProviderTenantRestriction { + param($Restriction, [string] $ProviderTenantId) + + return $Restriction -and $Restriction.kind -eq 'allowedTenants' -and + $Restriction.isHomeTenantAllowed -eq $true -and + @($Restriction.allowedTenantIds).Count -eq 1 -and + $Restriction.allowedTenantIds[0] -eq $ProviderTenantId +} + +function Set-EppProviderTenantRestriction { + param([string] $ApplicationObjectId, [string] $ProviderTenantId) + + Invoke-MgGraphRequest -Method PATCH ` + -Uri "https://graph.microsoft.com/beta/applications/$ApplicationObjectId" ` + -Body @{ + signInAudience = 'AzureADMultipleOrgs' + signInAudienceRestrictions = @{ + '@odata.type' = '#microsoft.graph.allowedTenantsAudience' + kind = 'allowedTenants' + isHomeTenantAllowed = $true + allowedTenantIds = @($ProviderTenantId) + } + } -ContentType 'application/json' -ErrorAction Stop | Out-Null +} + +function Get-EppResourceProviderRequirements { + @( + @{ Namespace = 'Microsoft.Web'; Type = 'sites' } + @{ Namespace = 'Microsoft.Storage'; Type = 'storageAccounts' } + @{ Namespace = 'Microsoft.KeyVault'; Type = 'vaults' } + @{ Namespace = 'Microsoft.OperationalInsights'; Type = 'workspaces' } + @{ Namespace = 'Microsoft.Insights'; Type = 'components' } + @{ Namespace = 'Microsoft.ManagedIdentity'; Type = 'userAssignedIdentities' } + ) +} + +function Get-EppResourceProviders { + param([string] $SubscriptionId) + + foreach ($provider in Get-EppResourceProviderRequirements) { + $registration = Invoke-EppAz provider show --namespace $provider.Namespace --subscription $SubscriptionId --output json | + ConvertFrom-Json + if (-not $registration -or -not $registration.PSObject.Properties['registrationState'] -or + $registration.registrationState -notin @('Registered', 'Registering', 'NotRegistered', 'Unregistering')) { + throw "Azure returned an unsupported registration state for '$($provider.Namespace)'." + } + if ($registration.registrationState -eq 'Unregistering') { + throw "Resource provider '$($provider.Namespace)' is being unregistered. Let that operation finish before rerunning setup; it will not be reversed automatically." + } + $locations = @() + if ($registration.PSObject.Properties['resourceTypes'] -and $registration.resourceTypes) { + $locations = @($registration.resourceTypes | Where-Object { $_ -and $_.resourceType -eq $provider.Type } | ForEach-Object locations) + } + [pscustomobject]@{ + Namespace = $provider.Namespace; Type = $provider.Type + RegistrationState = $registration.registrationState; Locations = $locations + } + } +} + +function Test-EppProviderLocation { + param($Provider, [string] $Location) + + return @($Provider.Locations | Where-Object { ($_ -replace '[^a-zA-Z0-9]', '') -ieq $Location }).Count -gt 0 +} + +function Assert-EppProviderLocations { + param([object[]] $Providers, [string] $Location) + + foreach ($provider in $Providers) { + if ($provider.RegistrationState -eq 'Registered' -and -not (Test-EppProviderLocation $provider $Location)) { + throw "'$($provider.Namespace)/$($provider.Type)' is unavailable in '$Location'. Choose another location." + } + } +} + +function Assert-EppPremiumLocation { + param([hashtable] $Inputs) + + $endpoint = "https://management.azure.com/subscriptions/$($Inputs.SubscriptionId)/providers/Microsoft.Web/geoRegions" + $required = @{ 'api-version' = '2024-04-01'; sku = 'ElasticPremium'; linuxWorkersEnabled = 'true' } + $parameters = @{} + $required + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $queryPath = Join-Path ([IO.Path]::GetTempPath()) "epp-regions-$([Guid]::NewGuid().ToString('N')).json" + try { + for ($pageNumber = 1; $pageNumber -le 20; $pageNumber++) { + # A query file keeps ampersands and continuation tokens away from Windows az.cmd parsing. + $parameters | ConvertTo-Json | Set-Content -LiteralPath $queryPath -Encoding utf8NoBOM + $page = Invoke-EppAz rest --method get --url $endpoint --url-parameters "@$queryPath" ` + --subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json -AsHashtable + if ($page -isnot [Collections.IDictionary] -or $page['value'] -isnot [Array]) { + throw 'Azure returned an invalid Elastic Premium region response.' + } + if (@($page['value'] | Where-Object { + $_ -and $_['name'] -is [string] -and ($_['name'] -replace '[^a-zA-Z0-9]', '') -ieq $Inputs.Location + }).Count) { return } + if (-not $page['nextLink']) { throw "Linux Premium EP1 is unavailable in '$($Inputs.Location)'." } + $next = $null + if (-not [Uri]::TryCreate([string]$page['nextLink'], [UriKind]::Absolute, [ref]$next) -or + $next.Scheme -ne 'https' -or $next.Port -ne 443 -or $next.UserInfo -or $next.Fragment -or + $next.GetLeftPart([UriPartial]::Path) -ine $endpoint -or -not $seen.Add($next.AbsoluteUri)) { + throw 'Azure returned an invalid or repeated Elastic Premium region continuation link.' + } + $parameters = @{} + $required + foreach ($pair in $next.Query.TrimStart('?').Split('&', [StringSplitOptions]::RemoveEmptyEntries)) { + $parts = $pair.Split('=', 2) + if ($parts.Count -ne 2) { throw 'Azure returned an invalid region continuation parameter.' } + $name = [Uri]::UnescapeDataString($parts[0].Replace('+', ' ')) + $value = [Uri]::UnescapeDataString($parts[1].Replace('+', ' ')) + if ($required.ContainsKey($name) -and $required[$name] -cne $value) { + throw 'Azure region pagination changed the approved Elastic Premium/Linux filter.' + } + $parameters[$name] = $value + } + } + throw 'Azure region pagination exceeded the supported page limit.' + } + finally { + if (Test-Path -LiteralPath $queryPath) { Remove-Item -LiteralPath $queryPath -Force } + } +} + +function Test-EppRegistrationDelay { + param([string] $Message) + + if ($Message -notmatch '\b(MissingSubscriptionRegistration|SubscriptionNotRegistered)\b') { return $false } + foreach ($provider in Get-EppResourceProviderRequirements) { + if ($Message -match ('(?