Skip to content

Commit 5f9dfe9

Browse files
author
Hou Chi Chan
committed
External Phone Provider OTP-delivery Azure Function sample (JavaScript, .NET, Python)
Squashed history of the sample: SAS-authenticated endpoint that parses the routing envelope, decrypts the JWE delivery context, and dispatches OTP delivery to a configured provider (Infobip, Telesign, Sinch, Soprano) with fail-closed outcomes.
0 parents  commit 5f9dfe9

63 files changed

Lines changed: 5814 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
javascript:
10+
name: JavaScript (Node.js)
11+
runs-on: ubuntu-latest
12+
defaults:
13+
run:
14+
working-directory: javascript
15+
strategy:
16+
matrix:
17+
node-version: [20.x, 22.x]
18+
steps:
19+
- uses: actions/checkout@v4
20+
- name: Use Node.js ${{ matrix.node-version }}
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: ${{ matrix.node-version }}
24+
- run: npm ci
25+
- run: npm test
26+
27+
dotnet:
28+
name: C# (.NET isolated)
29+
runs-on: ubuntu-latest
30+
defaults:
31+
run:
32+
working-directory: dotnet
33+
steps:
34+
- uses: actions/checkout@v4
35+
- uses: actions/setup-dotnet@v4
36+
with:
37+
dotnet-version: '8.0.x'
38+
- run: dotnet build
39+
- run: dotnet test tests
40+
41+
python:
42+
name: Python (v2 model)
43+
runs-on: ubuntu-latest
44+
defaults:
45+
run:
46+
working-directory: python
47+
steps:
48+
- uses: actions/checkout@v4
49+
- uses: actions/setup-python@v5
50+
with:
51+
python-version: '3.11'
52+
- run: pip install -r requirements.txt pytest
53+
- run: python -m pytest tests

‎.gitignore‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Dependencies
2+
node_modules/
3+
4+
# Python
5+
.venv/
6+
venv/
7+
*.pyc
8+
.pytest_cache/
9+
10+
# Azure Functions local settings — may contain secrets; never commit.
11+
local.settings.json
12+
13+
# Keys / certificates — never commit private keys.
14+
*.pem
15+
*.pfx
16+
17+
# Azure Functions build/runtime
18+
bin/
19+
obj/
20+
.azure/
21+
.python_packages/
22+
__pycache__/
23+
24+
# Logs
25+
*.log
26+
npm-debug.log*
27+
28+
# Editor / OS
29+
.vscode/
30+
.DS_Store
31+
Thumbs.db

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Azure Samples
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# External Phone Provider — Azure Function Sample
2+
3+
A provider-agnostic **OTP-delivery Azure Function** sample, implemented across multiple languages.
4+
Each language folder is a self-contained implementation of the **same design and the same
5+
[contract](docs/CONTRACT.md)** — one engine, drop-in provider adapters, env-provisioned config, and
6+
secrets in Key Vault.
7+
8+
## Implementations
9+
10+
| Language | Status | Folder |
11+
|----------|--------|--------|
12+
| JavaScript (Node.js) | ✅ Available | [`javascript/`](javascript/) |
13+
| C# (.NET isolated worker) | ✅ Available | [`dotnet/`](dotnet/) |
14+
| Python (v2 model) | ✅ Available | [`python/`](python/) |
15+
16+
All implementations conform to the **language-agnostic contract** in
17+
[`docs/CONTRACT.md`](docs/CONTRACT.md) — identical HTTP API, provider-adapter shape, config/env var
18+
names, Key Vault secret names, and behaviors (fail-closed, managed identity, privacy). Pick any folder
19+
and follow its README.
20+
21+
New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — setup, config, running, securing,
22+
and deploying, step by step.
23+
24+
## The design in one line
25+
26+
`POST /api/SendOtp` → validate token → resolve provider → fetch secret from Key Vault (managed
27+
identity) → provider adapter builds the request → send with a timeout → map the provider status to an
28+
outcome and an HTTP status. **Fail-closed:** only a `Continue` outcome returns `200 accepted`.
29+
30+
See [`docs/CONTRACT.md`](docs/CONTRACT.md) for the full specification every implementation follows.
31+
32+
## Security
33+
34+
**Turn Easy Auth (App Service Authentication) ON — that is the primary gate.** Set
35+
`unauthenticatedClientAction` to `Return401` and pin `allowedApplications` to Microsoft's app id. The
36+
HTTP trigger is `authLevel: anonymous`, so with Easy Auth off nothing stands in front of the endpoint.
37+
38+
**Also set `EPP_REQUIRE_AUTH=true` in any real deployment.** Easy Auth lives outside the code, so a
39+
portal change or slot swap can drop it silently; in-process validation is the backstop. The Function
40+
then validates the caller's **Entra JWT** (audience = `EPP_EXPECTED_AUDIENCE`, issuer tenant =
41+
`EPP_TENANT_ID`, signature via JWKS) and returns **401** without a valid token. Provider secrets are read
42+
from **Key Vault** via **managed identity** — no keys or connection strings in code or config. Locally
43+
(`func start`) there is no Easy Auth, so `EPP_REQUIRE_AUTH` is the only gate. See
44+
[docs/ONBOARDING.md §5](docs/ONBOARDING.md) for how to test it with a token.
45+
46+
## Docs
47+
48+
- **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — customer setup / run / secure / deploy guide.
49+
- **[docs/CONTRACT.md](docs/CONTRACT.md)** — the language-agnostic contract every implementation follows.
50+
51+
## Contributing a language or provider
52+
53+
- **New provider** (in any language): add one adapter file exposing `manifest` + `buildRequest` +
54+
`parseResponse` — no engine changes. See the language folder's README.
55+
- **New language**: mirror the folder structure, implement the contract, add the same test scenarios,
56+
and wire it into [`.github/workflows/ci.yml`](.github/workflows/ci.yml).

‎docs/CONTRACT.md‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# External Phone Provider Function — Language-Agnostic Contract
2+
3+
This is the **source of truth** every language implementation (`javascript/`, `dotnet/`, `python/`)
4+
must conform to. If an implementation disagrees with this document, the implementation is wrong.
5+
6+
> **Naming.** "CYOT" (Choose Your Own Telecom) is the internal code name for this feature. It still
7+
> appears in wire-level identifiers that must not change — type names (`SendCyotOtpRequest`,
8+
> `CyotDeliveryContext`) and the caller's `User-Agent`. App settings use the `EPP_` prefix.
9+
10+
The design is intentionally simple: **one dispatch engine + drop-in provider adapters**. Adding a
11+
provider is adding one adapter file; adding a language is re-implementing this contract.
12+
13+
---
14+
15+
## 1. HTTP API
16+
17+
**Endpoint:** `POST /api/SendOtp` (Functions HTTP trigger, `authLevel: anonymous`; trust comes from
18+
the Entra token when `EPP_REQUIRE_AUTH=true`). This is the interface **SAS (StrongAuthenticationService)**
19+
calls. PII (phone number + the rendered message, which contains the passcode) is **encrypted** inside a
20+
JWE; the cleartext envelope carries routing/scheduling only.
21+
22+
### Request headers
23+
24+
| Header | Notes |
25+
|--------|-------|
26+
| `Authorization` | `Bearer <Entra token>` (audience = `EPP_EXPECTED_AUDIENCE`) |
27+
| `User-Agent` | e.g. `Microsoft-AzureMFA-SAS-CYOT/1.0` (logged) |
28+
| `x-ms-correlation-id` | sign-in correlation id (fallback for envelope `correlationId`) |
29+
| `x-ms-client-request-id` | per-attempt id (used as `messageId`) |
30+
31+
### Request body — `SendCyotOtpRequest` (cleartext envelope)
32+
33+
| Field | Required | Notes |
34+
|-------|----------|-------|
35+
| `type` | ✅ | envelope contract version, e.g. `microsoft.mfa.otpDeliver.v1` |
36+
| `tenantId` | | opaque routing guid (says nothing about the tenant) |
37+
| `correlationId` | | sign-in correlation; stitches SAS ↔ provider traces |
38+
| `channel` | ✅ | `CyotChannel` int: `1`=Sms, `2`=Voice (`0`=Undefined); the string forms `sms`/`voice` are also accepted |
39+
| `mode` | ✅ | `CyotDeliveryMode` int: `1`=Live, `2`=Evaluation (rehearsal — do **NOT** deliver); the string forms `live`/`evaluation` are also accepted |
40+
| `ttlSeconds` | | passcode validity remaining; `<= 0` is **logged as a warning** — the delivery still proceeds |
41+
| `encryptedDeliveryContext` | ✅ | JWE compact serialization (see below) |
42+
43+
`channel` not in `{1,2}`/`{sms,voice}` → `400`. `mode` not in `{1,2}`/`{live,evaluation}` → `400`. Missing/empty `encryptedDeliveryContext` → `400`.
44+
45+
### `encryptedDeliveryContext` (JWE)
46+
47+
Alg: **RSA-OAEP-256** (CEK wrap) + **A256GCM** (content). The JOSE protected header carries `kid`; the
48+
endpoint resolves the matching RSA private key (`EPP_DECRYPTION_KEY_PEM`, a Key Vault reference) and
49+
local dev) and decrypts. The compact JWE must have **exactly five non-empty segments** and stay within a
50+
size limit; `alg`/`enc` are pinned (only `RSA-OAEP-256` + `A256GCM` accepted) and the AES-GCM auth tag is
51+
verified before any plaintext is used. Decrypted plaintext = `CyotDeliveryContext`:
52+
53+
| Field | Required | Notes |
54+
|-------|----------|-------|
55+
| `nonce` | ✅ | value the endpoint MUST echo to prove decryption |
56+
| `phoneNumber` | ✅ | E.164, single canonical string |
57+
| `message` | ✅ | fully rendered + localized text; **contains the passcode**. For `voice`, the passcode digits are spaced so TTS reads them individually |
58+
| `extension` | | office voice only |
59+
| `locale` | | selects TTS voice for the voice channel |
60+
| `riskContext` | | `CyotRiskContext` (scenario, familiarity flags, ip/asn/geo, ja4/ja4h, …) |
61+
62+
Decryption failure → `400`. Missing `nonce` / `phoneNumber` / `message` → `400`.
63+
64+
### Response — `CyotEndpointResponse` (JSON)
65+
66+
```json
67+
{ "nonce": "<echo of request nonce>", "correlationId": "<echo>", "providerStatus": "accepted" }
68+
```
69+
70+
`accepted`/`pending` are **not** failures (provider queued it; acceptance ≠ delivery to the handset).
71+
The endpoint returns **`200`** on acceptance (any `2xx` counts as transport acceptance). On `2xx` **with a
72+
matching nonce**, SAS treats the send as handled. **Nonce mismatch / non-2xx / timeout → SAS falls back
73+
to native CAPP delivery.** `Evaluation` mode returns `200` + nonce echo without delivering.
74+
75+
---
76+
77+
## 2. Outcome → HTTP status mapping
78+
79+
The provider's parsed status is mapped via the adapter's `responseMapping` to an **outcome**, then to
80+
an HTTP status. **Fail-closed:** an unknown/unmapped status is treated as `Fail`.
81+
82+
| Outcome | HTTP | When |
83+
|---------|------|------|
84+
| `Continue` | `200` | recognized success status |
85+
| `Block` | `403` | provider says blocked |
86+
| `StepUp` | `409` | provider signals step-up / fraud escalation |
87+
| `Fail` | `429` | provider returned 429 |
88+
| `Fail` | `401` | provider returned 401/403 (auth) |
89+
| `Fail` | `400` | other provider 4xx |
90+
| `Fail` | `502` | other provider error, or missing credential/endpoint |
91+
| — | `504` | request to the provider timed out |
92+
| — | `502` | network error to the provider (non-timeout) |
93+
94+
---
95+
96+
## 3. Provider adapter contract
97+
98+
Each provider is one unit exposing three things:
99+
100+
- **`manifest`** — protocol facts only:
101+
- `id` — provider id (also the `Provider` value; endpoint app setting is `<ID>_ENDPOINT`)
102+
- `auth` — `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }` or `{ mode: 'oauth2' }`
103+
- `responseMapping` — map of provider status → `Continue` | `Fail` | `Block` | `StepUp` (+ `default`)
104+
- **`buildRequest({ channel, endpoint, dispatch, credential, env })`** → `{ url, method, headers, body }`
105+
- **`parseResponse({ httpStatus, ok, json })`** → `{ success, providerHttpStatus, providerMessageId,
106+
providerStatusName | providerStatusCode, providerStatusDescription }`
107+
108+
The engine auto-discovers adapters (a `providers/` folder or registration). Endpoints, senders, TTLs,
109+
etc. are **not** in the manifest — they are app settings (see §4).
110+
111+
---
112+
113+
## 4. Configuration (app settings / env)
114+
115+
Set by provisioning. **Identical names across all languages.**
116+
117+
| Key | Purpose |
118+
|-----|---------|
119+
| `EPP_PROVIDER_NAME` | active provider id (`infobip` \| `telesign` \| `sinch` \| `soprano`) |
120+
| `EPP_PROVIDER_ENDPOINT` | provider base URL (one provider is active per deployment) |
121+
| `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider |
122+
| `EPP_PROVIDER_TIMEOUT_MS` | outbound call timeout (default 1500) |
123+
| `EPP_DECRYPTION_KEY_PEM` | RSA private key for JWE decryption — PEM, or **base64 over the PEM** as the setup script writes it. A **Key Vault reference** in Azure |
124+
| `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal |
125+
| `EPP_REQUIRE_AUTH` | `true` → validate the Entra token in-process. **Recommended `true` in every deployment**; Easy Auth is the primary gate, this is the backstop |
126+
| `EPP_EXPECTED_AUDIENCE` | v1 token `aud` — the identifier URI `api://{host}/{appId}` |
127+
| `EPP_EXPECTED_ISSUER` | v1 issuer `https://sts.windows.net/{tenantId}/` |
128+
| `EPP_TENANT_ID` | your Entra tenant id |
129+
| `EPP_EXPECTED_CLIENT_ID` | caller `appid`/`azp` to admit — Microsoft's app `25ec60fa-f18d-41a4-b398-50044c90ce13`. Enforced by Easy Auth (`403`) and, when `EPP_REQUIRE_AUTH=true`, against the token's own claim (`401`) |
130+
| `EPP_LOG_PLAINTEXT` | **diagnostics only** — `true` writes the phone number and passcode to the log. Never enable in production |
131+
| `KEY_VAULT_URL` | Key Vault URI (provider API keys) |
132+
| `AZURE_CLIENT_ID` | set for a user-assigned managed identity |
133+
134+
**Secrets** (provider API keys, identity secrets like customer/api ids) live in **Key Vault**, referenced
135+
by name in the manifest and fetched at runtime via **managed identity** (needs the *Key Vault Secrets
136+
User* role). Never in code or config.
137+
138+
---
139+
140+
## 5. Required behaviors
141+
142+
- **Fail-closed** — only `Continue` → `200 accepted`; unknown status → `Fail`.
143+
- **Managed identity** — Key Vault access via managed identity only (user-assigned if `AZURE_CLIENT_ID`
144+
set, else system-assigned). No static credentials.
145+
- **Privacy** — the OTP code and phone number must **never** appear in logs or the response body (they
146+
appear only in the outbound provider request, which is the delivery itself). The single exception is
147+
`EPP_LOG_PLAINTEXT=true`, a **diagnostics-only** switch that logs the phone number, message, and
148+
passcode. It defaults to false and **must not be enabled in production**.
149+
- **Auth** — **Easy Auth must be ON** (`unauthenticatedClientAction=Return401`, `allowedApplications`
150+
pinned to Microsoft's app); the trigger is `authLevel: anonymous`, so it is the primary gate.
151+
`EPP_EXPECTED_CLIENT_ID` mismatches return `403`. Deployments should **also** set
152+
`EPP_REQUIRE_AUTH=true` to validate the Entra JWT in-process (audience = `EPP_EXPECTED_AUDIENCE`,
153+
issuer tenant = `EPP_TENANT_ID`, RS256, JWKS). No-op pass-through when false (local dev).
154+
155+
---
156+
157+
## 6. Conformance test scenarios
158+
159+
Every implementation ships tests covering at least:
160+
161+
1. Each provider builds an HTTPS request with the code present and the correct auth scheme.
162+
2. `Block` → 403; provider 4xx `Fail` → 400; 429 → 429; 401/403 → 401.
163+
3. Provider HTTP 200 with an **unknown** status still `Fail`s (fail-closed).
164+
4. Missing provider credential → 502; missing endpoint config → 502.
165+
5. Timeout → 504; network error → 502.
166+
6. Envelope validation: `400` on invalid JSON, unsupported `channel`, unsupported `mode`, missing
167+
`encryptedDeliveryContext`, decryption failure, and an incomplete delivery context.
168+
7. JWE round-trip: a context encrypted with RSA-OAEP-256 + A256GCM decrypts to the expected
169+
`nonce` / `phoneNumber` / `message`, and the response echoes the `nonce`.
170+
8. `Evaluation` mode → 200 + nonce echo, nothing sent.
171+
9. Privacy: OTP code and phone never in logs or response body.

‎docs/ONBOARDING.md‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Customer Onboarding
2+
3+
A high-level guide to setting up, securing, and deploying the External Phone Provider OTP Function. The
4+
steps are the same for every language (`javascript/`, `dotnet/`, `python/`); only the build/run commands
5+
differ (see each language's README). All config keys, Key Vault secret names, and behaviors are defined
6+
once in [CONTRACT.md](CONTRACT.md).
7+
8+
## 1. Pick a language and a provider
9+
10+
Choose an implementation folder and the SMS/voice provider you have an account with (Infobip,
11+
Telesign, Soprano, Sinch). One provider is active per deployment.
12+
13+
## 2. Store the provider secret in Key Vault
14+
15+
Provider API keys never live in code or app settings — put them in **Key Vault** under the names the
16+
adapter expects (see [CONTRACT.md §3](CONTRACT.md)). The Function reads them at runtime via its
17+
**managed identity**, which needs the *Key Vault Secrets User* role on the vault.
18+
19+
## 3. Configure
20+
21+
Set the app settings from [`local.settings.sample.json`](local.settings.sample.json) — locally in a
22+
`local.settings.json` file, in Azure as environment variables. The keys are identical across languages;
23+
the full catalog is in [CONTRACT.md §4](CONTRACT.md).
24+
25+
## 4. Run and send a test
26+
27+
Build/run per the language README, then `POST /api/SendOtp` with the cleartext envelope (the PII lives
28+
in the encrypted JWE — see [CONTRACT.md](CONTRACT.md)). A **`200`** with the echoed `nonce`
29+
(`{ "nonce": "<echo>", "correlationId": "<echo>", "providerStatus": "accepted" }`) means the provider
30+
**queued** it — delivery is asynchronous, so confirm via the provider's delivery report.
31+
32+
## 5. Secure it — turn Easy Auth ON
33+
34+
**Enable App Service Authentication (Easy Auth) on the Function App. This is the recommended posture and
35+
the primary gate** — the trigger itself is `authLevel: anonymous`, so with Easy Auth off the endpoint is
36+
open to the internet. Configure:
37+
38+
- `unauthenticatedClientAction` = **`Return401`**
39+
- `allowedApplications` = Microsoft's CYOT application id (`EPP_EXPECTED_CLIENT_ID`)
40+
41+
Anything else is then rejected before your code runs.
42+
43+
**Also set `EPP_REQUIRE_AUTH=true`.** Easy Auth is configured outside the code, so a portal change, slot
44+
swap, or redeploy can silently drop it and nothing in the app would notice. In-process validation of the
45+
**Entra JWT** (plus `EPP_EXPECTED_AUDIENCE` and `EPP_TENANT_ID`) is the backstop that fails closed if that
46+
happens, and it is the only auth available when running locally with `func start`.
47+
48+
To test, obtain a token for the expected audience and confirm: no token → 401, valid token → 200.
49+
50+
## 6. Deploy
51+
52+
Publish the chosen language folder to a Function App (see its README). Ensure the app's managed
53+
identity has Key Vault access and the same environment variables are set.
54+
55+
## 7. Add another provider
56+
57+
One adapter file — `manifest` + `buildRequest` + `parseResponse` — then store its secret in Key Vault
58+
and set its endpoint app setting. No engine changes. See [CONTRACT.md §3](CONTRACT.md).

0 commit comments

Comments
 (0)