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 9b04ac9..da31bad 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,29 @@ by default. Deploy each language separately, not all three to the same Function New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — 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 tools, Bicep, and provider JSON from GitHub. Supply +missing customer settings, select **JavaScript, .NET, or Python**, choose Telesign or Soprano, +**SMS or voice**, **Global or EU**, enter a resource prefix, and approve one complete resource plan. +Generated names add `epp` after the customer prefix. Missing required Azure resource providers +are registered automatically after approval. Package links and published checksums are +selected automatically. Setup publishes .NET for Linux and requests Azure remote build for Python; +customers do not build or deploy the source ZIPs manually. The .NET choice requires the .NET 8 SDK. +Application registration (Step 1) and policy activation (Step 3) remain manual. Missing provider +details are explicitly labelled test values and written to the real Function App settings; replace +them and complete Telesign API-key or Soprano OAuth onboarding before live delivery. + ## 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/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-javascript.zip) | Application and production dependencies | +| .NET | [epp-dotnet-source.zip](https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-dotnet-source.zip) | C# Function source and project file; build/publish before deployment | +| Python | [epp-python-source.zip](https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-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, @@ -42,14 +56,14 @@ project; Python requires remote build to install dependencies. Neither source ZI as a run-from-package artifact. GitHub's **Code > Download ZIP** is the whole source repository, not a Function deployment package. -After the packaging workflow is merged, each successful `main` build tests all three implementations, +The private test links above match `test/epp-single-script`. After the packaging workflow is merged +upstream, each successful `main` build tests all three implementations, builds and inspects the ZIPs, and publishes a new versioned release. Get those builds from [Latest release](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest). Older releases remain available; existing assets are not overwritten. Pull requests build downloadable workflow artifacts only and cannot publish releases. GitHub sign-in may be required for workflow artifacts, but public release downloads do not require a local build. Packaging does not deploy or -verify live provider delivery. The current preview is built from the packaging branch, not a merged -release of the separate provider feature branches. +verify live provider delivery. ## Build ZIPs Locally @@ -106,7 +120,7 @@ extend it deliberately if you add runtime assets, and never put secrets in appli ## The design in one line SAS → Easy Auth → anonymous HTTP handler (`POST /api/SendOtp`, validate envelope + decrypt JWE) → -configured provider (API key) → HTTP result with nonce on success. +configured provider (Telesign API key or Soprano OAuth) → HTTP result with nonce on success. Only provider acceptance returns the nonce for live requests. Incoming `mode: 2` (evaluation) is the generic shutter: after platform authentication, validate and decrypt, then echo the nonce without calling a provider. @@ -141,7 +155,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 for the selected channel and endpoint region. | +| `EPP_PROVIDER_CHANNEL` | Guided deployment | Selected `sms` or `voice` route; live requests for the other channel 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 client ID and outbound user-assigned managed identity client ID 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,9 +172,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. +3. For Telesign, store provider API credentials in Key Vault using the **exact names in the adapter + manifest** and grant the Function identity *Key Vault Secrets User*. For Soprano, configure the + provider tenant/scope and outbound managed-identity federation; no provider secret is stored. 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 diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 01ab72d..5794d38 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -151,8 +151,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`, @@ -186,12 +187,17 @@ 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`, `EPP_PROVIDER_SCOPE` | Soprano provider tenant and 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, @@ -206,9 +212,11 @@ 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. Telesign resolves its +API-key credentials from Key Vault. Soprano exchanges an outbound managed-identity assertion for a +token in the configured provider tenant/scope. 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 a10d438..8330756 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -7,32 +7,36 @@ define required credentials and options. No provider is preferred or selected by ## 1. Select and configure an adapter -Choose a registered adapter for the selected provider and an account supporting the required channels. -Set `EPP_PROVIDER_NAME` to its actual manifest id (`` is only a placeholder), and configure -its matching `EPP_PROVIDER_ENDPOINT` and required options. One provider is active per deployment; -request fields cannot change it. Purchasing or activating a subscription does not install an adapter. +Choose a registered adapter for the selected provider, channel, and endpoint region. Set +`EPP_PROVIDER_NAME` to its actual manifest id (`` is only a placeholder), and configure +the complete selected request URL in `EPP_PROVIDER_ENDPOINT`. One provider and channel route are +active per guided deployment; request fields cannot change them. Store credentials under the Key Vault secret names declared by the selected adapter's manifest, not in code or app settings. Grant the Function's managed identity *Key Vault Secrets User* access at the appropriate secret or vault scope. Confirm that the endpoint and credentials belong to the same account and environment. Individual API contracts stay in the adapters. -### Setup script compatibility +### Guided setup compatibility -The Preview 1 setup script creates the encryption-key secret, not the selected provider's API -credentials. Before live delivery, complete these steps: +The [EPP Step 2 setup](../setup/docs/README.md) uses one downloadable launcher, GitHub-hosted +language/provider catalogs, and one Bicep deployment approval. It downloads the selected language +ZIP, verifies its published checksum automatically, builds .NET for Linux or requests Azure remote +build for Python, and deploys the ready-to-run result. Application registration and policy activation +are manual. Authentication is provider-owned: Telesign uses API keys; Soprano uses OAuth +client-assertion exchange and creates the disclosed outbound federated identity credential. + +Before live delivery, complete these steps: 1. Set `KEY_VAULT_URL` to the vault containing the provider credentials. When it is the vault created by setup, use that vault's `vaultUri`; otherwise explicitly select the credential vault and grant the Function identity read access there. An encryption-key reference does not configure this client. -2. Store the API key under the selected manifest's `keyVaultSecretName` (`key_vault_secret_name` in - Python). If the manifest also declares `identityKeyVaultSecretName` - (`identity_key_vault_secret_name`), store the matching API/customer ID as a separate secret. - `EPP_PROVIDER_ACCOUNT_NAME` is a sender/account option, **not** that credential ID or the API key. - Keep secret values out of parameters, console transcripts and checked-in settings. -3. Give `EPP_PROVIDER_ENDPOINT` the **base URL expected by the adapter**. Bundled adapters append the - channel-specific API path. Do not pass an already complete send URL unless an adapter explicitly - expects it. Use the same account/environment for the endpoint and its credential pair. +2. For Telesign, store the API key and customer ID under the manifest's exact secret names. + `EPP_PROVIDER_ACCOUNT_NAME` is a sender/account option, **not** either credential. For Soprano, + complete provider consent/application-role onboarding for the existing multitenant application; + the Function stores no Soprano client secret. +3. Give `EPP_PROVIDER_ENDPOINT` the complete provider-approved URL for the selected channel and + Global/EU region. The Telesign and Soprano adapters use it exactly and do not append a route. 4. Supply any additional options read by the selected adapter. Registering a provider does not make every account option or channel automatically available. @@ -41,7 +45,11 @@ The script already writes the correct `EPP_` names; no variable-prefix translati | Setup value | Current application behavior | |---|---| | `EPP_PROVIDER_NAME` | Selects one registered adapter; no implicit default. | -| `EPP_PROVIDER_ENDPOINT` | Base URL, with the final send path built by the adapter. | +| `EPP_PROVIDER_ENDPOINT` | Complete selected provider request URL. | +| `EPP_PROVIDER_CHANNEL` | Restricts live delivery to the selected `sms` or `voice` route. | +| `EPP_PROVIDER_ENDPOINT_REGION` | Records the selected `global` or `eu` route. | +| `EPP_PROVIDER_AUTH_MODE` | `apiKey` for Telesign; `oauth` for Soprano. | +| `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE` | Soprano OAuth target tenant and scope. | | `EPP_PROVIDER_TIMEOUT_MS` | Default 1500 ms; positive decimal values are capped at 2500 ms. Zero/invalid values use the default, not an infinite timeout. | | `EPP_PROVIDER_RETRY_INTERVAL_MS` | Not consumed. Calls are not automatically retried; writing this setting does not enable retries. | | `EPP_PROVIDER_ACCOUNT_NAME` | Adapter-specific sender/account option, separate from credential secrets. | @@ -49,8 +57,8 @@ The script already writes the correct `EPP_` names; no variable-prefix translati | `EPP_ENCRYPTION_KEY_ID` | Advisory mismatch warning only; not overlapping-key selection. | | `EPP_EXPECTED_AUDIENCE`, `EPP_EXPECTED_ISSUER`, `EPP_EXPECTED_CLIENT_ID`, `EPP_TENANT_ID` | The script may write these, but this platform-authenticated application does not read them. The script's separate Easy Auth configuration enforces caller trust. | -**Do not use the script's `-NoEasyAuth` option with this application.** There is no application token -validator to take over. For the script's v1 registration, configure Easy Auth with the identifier URI +**Do not disable Easy Auth with this application.** There is no application token +validator to take over. For a v1 registration, configure Easy Auth with the identifier URI as audience, `https://sts.windows.net/{tenantId}/` as issuer, and the authorized SAS application in `allowedApplications`. Use the v2 audience/issuer only when the registration actually issues v2 tokens. No Entra application role check is performed. Azure RBAC grants to the Function's managed identity @@ -66,10 +74,10 @@ The script alone does not make this implementation conform to every Preview 1 re - The guide requires voice digits to be spoken separately. This implementation preserves the supplied message; verify the selected voice API's behavior rather than assuming unspaced digits are intelligible. -The pasted script also needs its advertised 100-byte UTF-8 endpoint-URL check before deployment. -A public-only certificate cannot supply the private key it later exports. Treat failed infrastructure -role assignments as failures unless the exact assignment is verified as already present. Verify these -script prerequisites separately; the application tests do not validate provisioning. +The guided deployment includes explicitly labelled dummy provider values for configuration testing. +These values are written into the actual Function App environment; they do not establish provider +connectivity. Replace the selected route with provider-approved settings, provision Telesign Key +Vault credentials or Soprano provider consent as applicable, and verify deployed security controls. ## 2. Provision encryption and deployment trust diff --git a/dotnet/README.md b/dotnet/README.md index 2a45212..57a7b9f 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 1163547..deff095 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; @@ -207,6 +209,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) { @@ -230,16 +235,23 @@ public async Task DispatchAsync(DispatchRequest dispatch, string if (!OutcomeMapper.DefaultChannels.Contains(channel)) return new DispatchResult(400, new { status = "error", provider = providerId, reason = "unsupported channel", 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)); @@ -284,11 +296,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 e8f14c6..be14ad6 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -26,7 +26,7 @@ public sealed record DispatchRequest( string? CorrelationId, string? Locale); -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 7ffb5df..c9ebf56 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, @@ -28,8 +28,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 { @@ -40,7 +39,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc shutterMode = false, }; - 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 79978ad..c1f366b 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -28,10 +28,8 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc var externalId = dispatch.CorrelationId ?? dispatch.MessageId; var form = new Dictionary(); - string path; if (channel == "voice") { - path = "/v1/voice"; form["phone_number"] = dispatch.Destination; form["message"] = dispatch.Message ?? string.Empty; form["message_type"] = "OTP"; @@ -40,7 +38,6 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc } else { - path = "/v1/messaging"; form["phone_number"] = dispatch.Destination; form["message"] = dispatch.Message ?? string.Empty; form["sender_id"] = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? string.Empty; @@ -56,7 +53,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc ["Accept"] = "application/json", }; var encoded = string.Join("&", form.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); - return new ProviderHttpRequest($"{endpoint}{path}", "POST", headers, encoded); + return new ProviderHttpRequest(endpoint, "POST", headers, encoded); } public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index e78a06b..cea4a2c 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -13,15 +13,14 @@ private static DispatchRequest Request(string channel = "sms") => [Theory] [InlineData("sms")] [InlineData("voice")] - public void SopranoUsesExactOmnimsgContract(string channel) + public void SopranoUsesSelectedEndpointAndOAuth(string channel) { - var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", Request(channel), - 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", Request(channel), + 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("Bearer provider-token", request.Headers["Authorization"]); Assert.Equal("application/json", request.Headers["Accept"]); Assert.Equal("application/json", request.Headers["Content-Type"]); var expected = new @@ -67,10 +66,10 @@ public void OtherProvidersKeepTheirStaticAuthenticationAndProtocols() using var smsJson = JsonDocument.Parse(sms.Body); Assert.Equal(Request().Message, smsJson.RootElement.GetProperty("messages")[0].GetProperty("content").GetProperty("text").GetString()); - var form = new TelesignProvider().BuildRequest("sms", "https://provider.example", Request(), credential, env); + var form = new TelesignProvider().BuildRequest("sms", "https://provider.example/epp/sms", Request(), credential, env); Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test-id:test-key")), form.Headers["Authorization"]); Assert.Equal("application/x-www-form-urlencoded", form.Headers["Content-Type"]); - Assert.EndsWith("/v1/messaging", form.Url); + Assert.Equal("https://provider.example/epp/sms", form.Url); Assert.Contains("message=" + Uri.EscapeDataString(Request().Message!), form.Body); var call = new SinchProvider().BuildRequest("voice", "https://provider.example", Request("voice"), credential, env); diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 6324883..dca76cc 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,7 +31,7 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() entered.TrySetResult(); return release.Task.WaitAsync(cancellation); }; - var pending = rig.Invoke(channel: "voice"); + var pending = rig.Invoke(channel: "sms"); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -39,11 +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.Equal(Message, body.RootElement.GetProperty("text").GetString()); + 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(); @@ -79,6 +79,8 @@ 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://provider.example/epp/sms"; rig.Secrets.Identity = ""; AssertFailure(rig, await rig.Invoke(), 502); rig.Secrets.Identity = "private-api-id"; @@ -240,8 +242,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[] @@ -285,7 +287,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 is "soprano-api-id" or "telesign-customer-id" ? Identity : Secret); } } @@ -294,7 +296,7 @@ private sealed class TestHttp : HttpMessageHandler, IHttpClientFactory public int Calls { get; private set; } public string? Body { get; private set; } 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 293e006..964b787 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 e33383e..0c6682b 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 } = require('./models'); @@ -162,6 +162,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]); @@ -196,15 +198,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. @@ -305,6 +330,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) }; + } const endpointBaseUrl = config.providerEndpoint; if (!isValidProviderUrl(endpointBaseUrl)) { @@ -317,9 +348,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) }; } @@ -398,4 +430,5 @@ module.exports = { outcomeToHttpStatus, parseProviderTimeout, isValidProviderUrl, + resolveProviderCredential, }; diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index de86805..9faed9b 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -8,11 +8,7 @@ const { ParsedResponse } = require('../models'); const manifest = { id: 'soprano', - auth: { - mode: 'apiKey', - keyVaultSecretName: 'soprano-api-key', - identityKeyVaultSecretName: 'soprano-api-id', - }, + auth: { mode: 'oauth' }, responseMapping: { ENROUTE: 'Continue', ACCEPTED: 'Continue', @@ -29,13 +25,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); @@ -46,7 +39,7 @@ function buildRequest({ channel, endpoint, dispatch, credential }) { correlationId: dispatch.correlationId || dispatch.messageId, shutterMode: false, }; - 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 5ba03a1..b37d081 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -33,10 +33,8 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { const contentType = 'application/x-www-form-urlencoded'; const authorization = `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`; - let path; let params; if (channel === 'voice') { - path = '/v1/voice'; params = new URLSearchParams({ phone_number: dispatch.destination, message: dispatch.message, @@ -45,7 +43,6 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { external_id: dispatch.correlationId || dispatch.messageId, }); } else { - path = '/v1/messaging'; params = new URLSearchParams({ phone_number: dispatch.destination, message: dispatch.message, @@ -57,7 +54,7 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { } return { - url: `${base}${path}`, + url: base, method: 'POST', headers: { Authorization: authorization, diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 28d7f51..d7fa8fc 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, 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, + parseEnvelope, parseProviderTimeout, isValidProviderUrl, 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,17 @@ 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, + credential: { mode: 'oauth', accessToken: 'provider-token' }, + env: undefined, + endpoint: `${input.endpoint}/oauth/messages`, + }); + 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: 'Bearer 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, @@ -108,8 +114,8 @@ test('App-auth SMS preserves its request and normalizes acceptance', () => { }); test('Basic-auth SMS preserves its form request and normalizes acceptance', () => { - const request = getProvider('telesign').adapter.buildRequest(input); - assert.equal(request.url, 'https://provider.example/v1/messaging'); + const request = getProvider('telesign').adapter.buildRequest({ ...input, endpoint: 'https://provider.example/epp/sms' }); + assert.equal(request.url, 'https://provider.example/epp/sms'); assert.equal(request.headers.Authorization, `Basic ${Buffer.from('id:key').toString('base64')}`); assert.equal(request.headers['Content-Type'], 'application/x-www-form-urlencoded'); assert.equal(new URLSearchParams(request.body).get('message'), dispatch.message); @@ -150,11 +156,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 [ @@ -178,3 +184,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 0d79552..536f2b3 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -36,11 +36,12 @@ beforeEach(() => { for (const key of envKeys) delete process.env[key]; 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/' }); + KEY_VAULT_URL: 'https://unit-test.vault.azure.net', EPP_PROVIDER_NAME: 'telesign', + EPP_PROVIDER_ENDPOINT: 'https://provider.example/epp/send', + EPP_PROVIDER_AUTH_MODE: 'apiKey' }); 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' }) })); + fetchMock = mock.method(global, 'fetch', async () => ({ ok: true, status: 200, + text: async () => JSON.stringify({ reference_id: 'PRIVATE-ID', status: { code: 290, description: 'PRIVATE-STATUS' } }) })); }); afterEach(() => { mock.restoreAll(); @@ -165,8 +166,10 @@ test('SMS/voice preserve content and correlation without reflecting headers or l assert.equal(result.status, 200); assert.deepEqual(result.jsonBody, { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }); const init = fetchMock.mock.calls.at(-1).arguments[1]; - const sent = JSON.parse(init.body); - assert.deepEqual([sent.text, sent.messageTypes, sent.correlationId], [delivery.message, [name], correlationId]); + const sent = new URLSearchParams(init.body); + assert.deepEqual([sent.get('message'), sent.get('message_type'), sent.get('external_id')], + [delivery.message, 'OTP', correlationId]); + assert.equal(fetchMock.mock.calls.at(-1).arguments[0], 'https://provider.example/epp/send'); assert.equal(init.redirect, 'manual'); assert.equal(logs.length, 1); assert.deepEqual(Object.keys(logs[0]).sort(), ['correlationId', 'elapsedMs', 'evaluation', 'httpStatus', 'requestId']); diff --git a/python/README.md b/python/README.md index de7afdb..1c44fd4 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 47c7ccd..f1a58eb 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 @@ -240,6 +243,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) @@ -256,15 +261,21 @@ 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} 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 @@ -331,10 +342,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 8564af4..13cdec8 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 93e088b..b06a749 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -6,11 +6,7 @@ class SopranoProvider: manifest = { "id": "soprano", - "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", @@ -21,8 +17,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", } @@ -33,7 +28,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env): "correlationId": dispatch.correlation_id or dispatch.message_id, "shutterMode": False, } - 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 58bd95a..6c255d8 100644 --- a/python/src/providers/telesign.py +++ b/python/src/providers/telesign.py @@ -25,7 +25,6 @@ def build_request(self, channel, endpoint, dispatch, credential, env): external_id = dispatch.correlation_id or dispatch.message_id if channel == "voice": - path = "/v1/voice" form = { "phone_number": dispatch.destination, "message": dispatch.message or "", @@ -34,7 +33,6 @@ def build_request(self, channel, endpoint, dispatch, credential, env): "external_id": external_id, } else: - path = "/v1/messaging" form = { "phone_number": dispatch.destination, "message": dispatch.message or "", @@ -45,7 +43,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env): } headers = {"Authorization": authorization, "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"} - return {"url": f"{endpoint}{path}", "method": "POST", "headers": headers, "body": urllib.parse.urlencode(form)} + return {"url": endpoint, "method": "POST", "headers": headers, "body": urllib.parse.urlencode(form)} def parse_response(self, http_status, ok, json_body): status = json_body.get("status") or {} if isinstance(json_body, dict) else {} diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index 9bf87f7..2b1d47e 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -20,15 +20,15 @@ def _dispatch(channel="sms"): @pytest.mark.parametrize("channel", ["sms", "voice"]) -def test_soprano_exact_sms_and_voice_contract(channel): +def test_soprano_uses_selected_endpoint_and_oauth(channel): 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(channel), + {"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": "Bearer provider-token", "Content-Type": "application/json", "Accept": "application/json", } assert json.loads(request["body"]) == { @@ -59,10 +59,10 @@ def test_infobip_sms_request_and_response_contract(): def test_telesign_sms_request_and_response_contract(): request = TelesignProvider().build_request( - "sms", "https://telesign.example", _dispatch(), + "sms", "https://telesign.example/epp/sms", _dispatch(), {"mode": "apiKey", "secret": "key", "identity": "customer"}, {}, ) - assert request["method"] == "POST" and request["url"] == "https://telesign.example/v1/messaging" + assert request["method"] == "POST" and request["url"] == "https://telesign.example/epp/sms" assert request["headers"]["Authorization"] == "Basic " + base64.b64encode(b"customer:key").decode() assert request["headers"]["Content-Type"] == "application/x-www-form-urlencoded" form = parse_qs(request["body"]) diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index 6d352f8..d4f4edd 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -18,15 +18,20 @@ 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/"}) + 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_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" +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() @@ -37,6 +42,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 07a088d..f4b5903 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..f313c5d --- /dev/null +++ b/setup/Setup-Epp.ps1 @@ -0,0 +1,84 @@ +#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. +.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] $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..ede36fe --- /dev/null +++ b/setup/docs/README.md @@ -0,0 +1,228 @@ +# 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 and timings are preserved. Soprano uses labelled test tenant, scope, app-ID, endpoint, 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 register and onboard 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 application. Select **Accounts in any organizational directory**; do not enable + personal Microsoft accounts. No redirect URI or client secret is needed for this endpoint. +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. Verify the application's enterprise application exists in the same tenant. In **Enterprise + applications > Properties**, set **Assignment required?** to **No** as required by EPP onboarding. + Easy Auth will still pin inbound calls to the Microsoft phone-provider application + `25ec60fa-f18d-41a4-b398-50044c90ce13`; this is not permission to accept arbitrary callers. +4. Verify the app's access-token version in its manifest. Setup reads `api.requestedAccessTokenVersion` + and configures the corresponding v1 or v2 issuer/audience. Leave **`tokenEncryptionKeyId` null**: + Easy Auth expects a signed bearer JWT. Payload JWE encryption is separate. +5. 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. + +Step 2 still configures endpoint-specific properties on this **existing** application: its +hostname-based identifier URI and public JWE encryption certificate. Those changes are included +in the single deployment approval. Soprano additionally creates the disclosed outbound +managed-identity federated credential; Telesign does not. + +## 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+** and its Bicep compiler, with access to GitHub, Azure, Microsoft Graph, and + Key Vault. Python additionally needs network access to the Function App's SCM endpoint. +- Microsoft Graph PowerShell modules `Microsoft.Graph.Authentication` and `Microsoft.Graph.Applications`. +- An Azure **user** account permitted to deploy at subscription scope, create the listed resources, + and create the scoped role assignments. Service-principal provisioning is not supported. +- Application-management permission in the customer tenant and delegated Graph + `Application.ReadWrite.All` for endpoint-specific application configuration. +- **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. + +Install prerequisites once, if missing: + +```powershell +Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Repository PSGallery +Install-Module Microsoft.Graph.Applications -Scope CurrentUser -Repository PSGallery +az bicep install +``` + +Install Azure CLI through its official installation instructions if necessary. Sign in before +running setup; Azure CLI and Microsoft Graph have separate authentication sessions: + +```powershell +az login --tenant +``` + +Setup checks the explicitly supplied subscription and tenant without changing the CLI's selected +subscription. It requests Graph sign-in before displaying the plan if a suitable delegated +session is not already available. Authentication/MFA 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. **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. +6. **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..5eff6e7 --- /dev/null +++ b/setup/docs/Troubleshooting.md @@ -0,0 +1,166 @@ +# 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 + +This is intentional for deployment testing. Both JSON profiles explicitly use +`deployment.testConfiguration: true`. Every SMS/voice and Global/EU route is complete; zero GUIDs +and `example.invalid` URLs are written into the actual Function App environment when that route is +selected, with `EPP_PROVIDER_TEST_CONFIGURATION=true`. Telesign's supplied channel URLs and timings +are retained. + +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. + +The customer application and enterprise application must already exist from manual Step 1. +Graph needs delegated `Application.ReadWrite.All` for endpoint URI/key configuration. Noninteractive +runs must authenticate both clients first and supply `-ApproveDeployment` separately. +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..5461a25 --- /dev/null +++ b/setup/packages/catalog.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "packages": [ + { + "id": "javascript", + "displayName": "JavaScript", + "url": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-javascript.zip", + "checksumsUrl": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/SHA256SUMS.txt", + "buildStrategy": "ready" + }, + { + "id": "dotnet", + "displayName": ".NET", + "url": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-dotnet-source.zip", + "checksumsUrl": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/SHA256SUMS.txt", + "buildStrategy": "dotnet-publish" + }, + { + "id": "python", + "displayName": "Python", + "url": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-python-source.zip", + "checksumsUrl": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-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..4975656 --- /dev/null +++ b/setup/providers/soprano.json @@ -0,0 +1,46 @@ +{ + "deployment": { + "enabled": true, + "testConfiguration": true, + "providerName": "Soprano", + "authentication": { + "mode": "oauth", + "tenantId": "00000000-0000-0000-0000-000000000000" + }, + "routes": { + "sms": { + "global": { + "endpoint": "https://soprano-global.example.invalid/sms", + "appId": "00000000-0000-0000-0000-000000000000", + "scope": "api://00000000-0000-0000-0000-000000000000/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://soprano-eu.example.invalid/sms", + "appId": "00000000-0000-0000-0000-000000000000", + "scope": "api://00000000-0000-0000-0000-000000000000/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + }, + "voice": { + "global": { + "endpoint": "https://soprano-global.example.invalid/voice", + "appId": "00000000-0000-0000-0000-000000000000", + "scope": "api://00000000-0000-0000-0000-000000000000/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://soprano-eu.example.invalid/voice", + "appId": "00000000-0000-0000-0000-000000000000", + "scope": "api://00000000-0000-0000-0000-000000000000/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + } + }, + "note": "All Soprano tenant, endpoint, application ID, scope, and timing values are explicit test values until Soprano supplies the production profile." + } +} diff --git a/setup/providers/telesign.json b/setup/providers/telesign.json new file mode 100644 index 0000000..cd4e143 --- /dev/null +++ b/setup/providers/telesign.json @@ -0,0 +1,43 @@ +{ + "deployment": { + "enabled": true, + "testConfiguration": true, + "providerName": "Telesign", + "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..69fa5c7 --- /dev/null +++ b/setup/support/Epp.Setup.psm1 @@ -0,0 +1,1032 @@ +#Requires -Version 7.0 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$script:MicrosoftPhoneProviderAppId = '25ec60fa-f18d-41a4-b398-50044c90ce13' +. (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') } + + $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") + } + } + } + elseif ($authenticationMode -eq 'oauth') { + try { $null = ConvertTo-EppGuid $authentication['tenantId'] -AllowZero:$testConfiguration } + catch { $issues.Add('deployment.authentication.tenantId must identify the provider OAuth tenant') } + } + + $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_TEST_CONFIGURATION = $testConfiguration.ToString().ToLowerInvariant() + } + if ($authenticationMode -eq 'oauth') { + $settings.EPP_PROVIDER_TENANT_ID = ConvertTo-EppGuid $authentication['tenantId'] -AllowZero:$testConfiguration + $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 { + # The SDK and its sign-in context belong to the session, not this temporary helper module. + Import-Module Microsoft.Graph.Authentication -Global -ErrorAction Stop + Import-Module Microsoft.Graph.Applications -Global -ErrorAction Stop +} + +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 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 ('(?