Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 53 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# External Phone Provider Azure Function Sample
# External Phone Provider: Azure Function Sample

A provider-agnostic **OTP-delivery Azure Function** sample, implemented across multiple languages.
Each language folder is a self-contained implementation of the **same design and the same
[contract](docs/CONTRACT.md)** one engine, drop-in provider adapters, env-provisioned config, and
[contract](docs/CONTRACT.md)**: one engine, drop-in provider adapters, env-provisioned config, and
secrets in Key Vault.

## Implementations
Expand All @@ -14,15 +14,15 @@ secrets in Key Vault.
| Python (v2 model) | Available | [python/](python/) |

All implementations conform to the **language-agnostic contract** in
[docs/CONTRACT.md](docs/CONTRACT.md) identical HTTP API, provider-adapter shape, config/env var
[docs/CONTRACT.md](docs/CONTRACT.md): identical HTTP API, provider-adapter shape, config/env var
names, Key Vault secret names, and behaviors (fail-closed, managed identity, privacy). Pick any folder
and follow its README.

Choose one language and configure the adapter for your provider. No provider is preferred or selected
by default. Deploy each language separately, not all three to the same Function App. See the
[shared configuration](docs/CONTRACT.md#default-provider-and-configuration-readers).

New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** setup, config, running, securing,
New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** for setup, config, running, securing,
and deploying, step by step.

## The design in one line
Expand Down Expand Up @@ -59,7 +59,7 @@ how code accesses configuration, not the environment-variable names.
| Variable | When needed | Value |
|---|---|---|
| `AzureWebJobsStorage` | Functions host storage | Local sample: `UseDevelopmentStorage=true` with Azurite running. Configure Azure host storage separately for the selected plan. |
| `FUNCTIONS_WORKER_RUNTIME` | Functions host | `node`, `python`, or `dotnet-isolated`—exactly one value matching the chosen implementation. |
| `FUNCTIONS_WORKER_RUNTIME` | Functions host | `node`, `python`, or `dotnet-isolated`. Choose the value matching your implementation. |
| `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. |
Expand All @@ -78,6 +78,8 @@ how code accesses configuration, not the environment-variable names.
3. Store provider API keys and any required identity secrets in Key Vault using the **exact names in
the adapter manifest**. Grant that app/slot's managed identity *Key Vault Secrets User* on those
secrets. An API key in a local environment variable is not a supported replacement for the resolver.
See the [provider credential naming table](docs/ONBOARDING.md#provider-credential-names) and
[local use of existing cloud secrets](docs/ONBOARDING.md#local-settings-and-cloud-secrets).

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
Expand All @@ -93,6 +95,49 @@ Configure inbound issuer/audience/caller trust in **Easy Auth**, not these appli
Incoming `tenantId`, `channel`, `mode` and `ttlSeconds` are request data. No outbound OAuth settings
are supported by this main-based implementation.

## Telesign EPP

The `telesign` adapter uses `POST https://verify.telesign.com/integration/msft/cyot`
for both SMS and Voice. Set `EPP_PROVIDER_NAME=telesign` and
`EPP_PROVIDER_ENDPOINT=https://verify.telesign.com` (the base URL, without the route).
This replaces the legacy `/v1/messaging` and `/v1/voice` integrations in all three languages.

Basic authentication uses `base64(customer-id:api-key)`, with the existing Key Vault secrets
`telesign-customer-id` and `telesign-api-key`. Digest and Phase 2 token authentication are not
implemented. The incoming caller's Authorization header is never forwarded.

The adapter builds the following JSON from the decrypted delivery context and envelope:

```json
{
"recipient": { "phone_number": "+1234567890" },
"message": { "text": "Your verification code is 4821", "language": "en" },
"channels": [{ "channel": "voice" }],
"correlation_id": "unique-string-123"
}
```

`phoneNumber` must match `^\+[1-9][0-9]{1,14}$`; the leading `+` is preserved. The complete
`message` is passed unchanged as `message.text`, including whitespace and OTP digit spacing.
Telesign performs text-to-speech for Voice; no separate speech object or OTP extraction is needed.
A nonblank string `locale` becomes `message.language`; otherwise language is omitted. The envelope
channel selects the single `sms` or `voice` entry. `correlation_id` uses a nonempty string request
correlation ID, falling back to the message ID for absent, empty, or non-string values. Reserved
`account_lifecycle_event` and `originating_ip` fields are
not sent; no client-IP inference or account-event default is applied. `TELESIGN_VOICE` and the
legacy sender/form fields no longer affect this adapter.

Telesign's API supports `X-Shutter-Mode: true` for direct provider tests. The Function deliberately
omits that header on live sends and does not forward it from incoming requests. Use the existing
`mode: 2` evaluation path for Function tests without delivery: it skips provider HTTP and credential
lookup entirely, rather than invoking Telesign shutter mode.

Responses normalize `reference_id` and `status.code`/`status.description` internally; provider
metadata is not logged or exposed in the public nonce response. Existing numeric success codes
are retained (SMS: 200, 203, 290-292; Voice: 100-103); the supplied EPP integration overview does not provide
a replacement status-code catalog. Missing, malformed, or unknown codes fail closed, as do
unsuccessful HTTP responses. Confirm these codes and account access with Telesign before production.

## Security

**Easy Auth (App Service Authentication) is the only caller-authentication gate, before the anonymous
Expand All @@ -114,13 +159,13 @@ authentication; [separate deployed security checks](docs/ONBOARDING.md#4-package

## Docs

- **[docs/ONBOARDING.md](docs/ONBOARDING.md)**customer setup / run / secure / deploy guide.
- **[docs/CONTRACT.md](docs/CONTRACT.md)** the language-agnostic contract every implementation follows.
- **[docs/ONBOARDING.md](docs/ONBOARDING.md)**: customer setup, security, deployment, and validation.
- **[docs/CONTRACT.md](docs/CONTRACT.md)**: the language-agnostic contract every implementation follows.

## Contributing a language or provider

- **New provider** (in any language): add one adapter file exposing `manifest` + `buildRequest` +
`parseResponse` no engine changes. See the language folder's README.
`parseResponse`; no engine changes. See the language folder's README.
- **New language**: mirror the folder structure, implement the contract, add the same test scenarios,
and wire it into [.github/workflows/ci.yml](.github/workflows/ci.yml).

Expand Down
32 changes: 17 additions & 15 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# External Phone Provider Function Language-Agnostic Contract
# External Phone Provider Function: Language-Agnostic Contract

This defines the shared contract for [JavaScript](../javascript/), [Python](../python/) and
[.NET](../dotnet/). See [production limitations](#production-limitations) before production use.
Expand Down Expand Up @@ -34,7 +34,7 @@ Forwarded headers, including `x-ms-client-principal`, do not establish trust by
replace the required Easy Auth gate. The handler does not use them to authenticate callers or forward
the incoming `Authorization` header to the provider. Configure Easy Auth as described in section 5.

### EPP request body `Envelope` (cleartext envelope)
### EPP request body: `Envelope` (cleartext envelope)

| Field | Required | Notes |
|-------|----------|-------|
Expand Down Expand Up @@ -141,19 +141,19 @@ success-looking status. Explicit `Block`/`StepUp` outcomes remain non-success re
| `Fail` | `401` | provider returned 401/403 (auth) |
| `Fail` | `400` | other provider 4xx |
| `Fail` | `502` | other provider error, or missing credential/endpoint |
| | `504` | request to the provider timed out |
| | `502` | network error to the provider (non-timeout) |
| N/A | `504` | request to the provider timed out |
| N/A | `502` | network error to the provider (non-timeout) |

---

## 3. Provider adapter contract

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
- `responseMapping` map of provider status → `Continue` | `Fail` | `Block` | `StepUp` (+ `default`)
- **`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
- `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`,
`providerHttpStatus`, optional `providerMessageId`, `providerStatusName`, `providerStatusCode`
Expand All @@ -174,8 +174,10 @@ class hierarchy is required.

Adapters require registration in the chosen runtime. Consult the selected adapter and its manifest
for required credentials and options: the manifest declares secret names and protocol mappings;
the implementation reads adapter-specific options from app settings. Do not duplicate individual
API contracts or credential catalogs in shared onboarding documentation.
the implementation reads adapter-specific options from app settings. Individual API contracts remain
in the adapters; the [onboarding credential naming table](ONBOARDING.md#provider-credential-names)
lists the exact manifest secret names for provisioning and authorized local tests. Keep that table
aligned with the manifests; never include secret values in documentation or the settings sample.

---

Expand Down Expand Up @@ -230,18 +232,18 @@ subscription activation and changing tenant policy belong to provisioning, not t

## 5. Required behaviors

- **Fail-closed** only `Continue` → `200 accepted`; unknown status → `Fail`.
- **Managed identity** Key Vault access via managed identity only (user-assigned if `AZURE_CLIENT_ID`
- **Fail-closed**: only `Continue` → `200 accepted`; unknown status → `Fail`.
- **Managed identity**: Key Vault access via managed identity only (user-assigned if `AZURE_CLIENT_ID`
set, else system-assigned). No static credentials.
- **Privacy** never log phone numbers, passcodes, nonce values, bearer tokens, API keys, JWE headers/payloads,
- **Privacy**: never log phone numbers, passcodes, nonce values, bearer tokens, API keys, JWE headers/payloads,
raw exceptions or provider responses. There is no plaintext diagnostic override. Each handler
writes one summary with a generated request ID, the first 16 lowercase hex characters of the
correlation ID's SHA256 hash, HTTP status,
elapsed milliseconds and evaluation flag. Original wire correlation IDs and the required nonce
echo remain unchanged. Hashes are pseudonymous, not anonymous; restrict log access and retention.
A configured encryption-key-ID mismatch adds a fixed warning, never either key ID or the JWE header.
Disable SDK, platform and proxy body tracing separately.
- **Platform authentication only** enable Easy Auth with `requireAuthentication=true`,
- **Platform authentication only**: enable Easy Auth with `requireAuthentication=true`,
`unauthenticatedClientAction=Return401` and `requireHttps=true`. Configure the trusted tenant issuer
and `allowedAudiences` for the endpoint app, plus a **nonempty `allowedApplications`** list pinned to
the authorized SAS caller application ID. No excluded path may bypass authentication for SendOtp.
Expand All @@ -251,7 +253,7 @@ subscription activation and changing tenant policy belong to provisioning, not t
or bypassed.** Core Tools supplies no Easy Auth: local execution must bind only to loopback, with
no tunnels or public forwarding. Neither request data, JWE decryption, a fixed nonce nor forwarded
principal headers authenticate the SAS caller.
- **Timeout boundaries** platform authentication and Key Vault retrieval happen outside the outbound HTTP
- **Timeout boundaries**: platform authentication and Key Vault retrieval happen outside the outbound HTTP
timer. Python uses connect/read inactivity timeouts, not a hard elapsed-time deadline. The cap
therefore does not guarantee a 3.2-second end-to-end response, especially on cold starts.
A timed-out POST may already have been accepted; avoid blind retries that duplicate messages.
Expand Down
Loading
Loading