Repository navigation
Add External Phone Provider OTP-delivery Azure Function sample (JavaScript, .NET, Python) #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
2c3e883
Add CYOT OTP-delivery Azure Function sample (JavaScript, .NET, Python)
9639330
Remove helper scripts from the sample
a33d40e
Address PR review: cross-language auth/robustness fixes
7beccaa
Rename customer-facing name from CYOT to External Phone Provider
c93c63f
Align all three samples with the External Phone Provider Preview 1 co…
4a4d7d4
Simplify the JavaScript dispatch layer
7b50896
Remove the unimplemented EPP_PROVIDER_RETRY_INTERVAL_MS setting from …
ac26baf
Drop the redundant passcode log line from all three samples
4cb44a4
Enforce the expected caller inside token validation, not only via Eas…
4398c5b
Drop config fields nothing reads, and log the delivery outcome in Python
4def292
Fold cyot.js into dispatch.js
a3fc399
Route the last three .NET classes through IEnv
004a9f0
Fold envelope and JWE handling into the dispatch module in Python and…
e446c22
Trim comments that restate the code in dispatch.js
c74b4d9
Fold Python models, outcome and registry into dispatch, and drop five…
cff5813
Remove test_cyot.py, duplicated by a bad stash pop
13a69ac
Fix Python startup blocker, trim dead code and redundant tests, recom…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
|
|
||
| jobs: | ||
| javascript: | ||
| name: JavaScript (Node.js) | ||
| runs-on: ubuntu-latest | ||
| defaults: | ||
| run: | ||
| working-directory: javascript | ||
| strategy: | ||
| matrix: | ||
| node-version: [20.x, 22.x] | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - name: Use Node.js ${{ matrix.node-version }} | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: ${{ matrix.node-version }} | ||
| - run: npm ci | ||
| - run: npm test | ||
|
|
||
| dotnet: | ||
| name: C# (.NET isolated) | ||
| runs-on: ubuntu-latest | ||
| defaults: | ||
| run: | ||
| working-directory: dotnet | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-dotnet@v4 | ||
| with: | ||
| dotnet-version: '8.0.x' | ||
| - run: dotnet build | ||
| - run: dotnet test tests | ||
|
|
||
| python: | ||
| name: Python (v2 model) | ||
| runs-on: ubuntu-latest | ||
| defaults: | ||
| run: | ||
| working-directory: python | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: '3.11' | ||
| - run: pip install -r requirements.txt pytest | ||
| - run: python -m pytest tests |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Dependencies | ||
| node_modules/ | ||
|
|
||
| # Python | ||
| .venv/ | ||
| venv/ | ||
| *.pyc | ||
| .pytest_cache/ | ||
|
|
||
| # Azure Functions local settings — may contain secrets; never commit. | ||
| local.settings.json | ||
|
|
||
| # Keys / certificates — never commit private keys. | ||
| *.pem | ||
| *.pfx | ||
|
|
||
| # Azure Functions build/runtime | ||
| bin/ | ||
| obj/ | ||
| .azure/ | ||
| .python_packages/ | ||
| __pycache__/ | ||
|
|
||
| # Logs | ||
| *.log | ||
| npm-debug.log* | ||
|
|
||
| # Editor / OS | ||
| .vscode/ | ||
| .DS_Store | ||
| Thumbs.db |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # CYOT 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 | ||
| secrets in Key Vault. | ||
|
|
||
| ## Implementations | ||
|
|
||
| | Language | Status | Folder | | ||
| |----------|--------|--------| | ||
| | JavaScript (Node.js) | ✅ Available | [`javascript/`](javascript/) | | ||
| | C# (.NET isolated worker) | ✅ Available | [`dotnet/`](dotnet/) | | ||
| | 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 | ||
| names, Key Vault secret names, and behaviors (fail-closed, managed identity, privacy). Pick any folder | ||
| and follow its README. | ||
|
|
||
| New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — setup, config, running, securing, | ||
| and deploying, step by step. | ||
|
|
||
| ## The design in one line | ||
|
|
||
| `POST /api/SendOtp` → validate token → resolve provider → fetch secret from Key Vault (managed | ||
| identity) → provider adapter builds the request → send with a timeout → map the provider status to an | ||
| outcome and an HTTP status. **Fail-closed:** only a `Continue` outcome returns `202 accepted`. | ||
|
|
||
| See [`docs/CONTRACT.md`](docs/CONTRACT.md) for the full specification every implementation follows. | ||
|
|
||
| ## Security | ||
|
|
||
| Set **`REQUIRE_AUTH=true`** in any real deployment. The Function then validates the caller's **Entra | ||
| JWT** (audience = `EXPECTED_AUDIENCE`, issuer tenant = `ISSUER_TENANT_ID`, signature via JWKS) and | ||
| returns **401** without a valid token. Provider secrets are read from **Key Vault** via **managed | ||
| identity** — no keys or connection strings in code or config. Locally, keep `REQUIRE_AUTH=false`. See | ||
| [docs/ONBOARDING.md §6](docs/ONBOARDING.md) for how to test it with a token. | ||
|
|
||
| ## 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. | ||
|
|
||
| ## 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. | ||
| - **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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| # CYOT Function — Language-Agnostic Contract | ||
|
|
||
| This is the **source of truth** every language implementation (`javascript/`, `dotnet/`, `python/`) | ||
| must conform to. If an implementation disagrees with this document, the implementation is wrong. | ||
|
|
||
| The design is intentionally simple: **one dispatch engine + drop-in provider adapters**. Adding a | ||
| provider is adding one adapter file; adding a language is re-implementing this contract. | ||
|
|
||
| --- | ||
|
|
||
| ## 1. HTTP API | ||
|
|
||
| **Endpoint:** `POST /api/SendOtp` (Functions HTTP trigger, `authLevel: anonymous`; trust comes from | ||
| the Entra token when `REQUIRE_AUTH=true`). This is the interface **SAS (StrongAuthenticationService)** | ||
| calls. PII (phone number + the rendered message, which contains the passcode) is **encrypted** inside a | ||
| JWE; the cleartext envelope carries routing/scheduling only. | ||
|
|
||
| ### Request headers | ||
|
|
||
| | Header | Notes | | ||
| |--------|-------| | ||
| | `Authorization` | `Bearer <Entra token>` (audience = `EXPECTED_AUDIENCE`) | | ||
| | `User-Agent` | e.g. `Microsoft-AzureMFA-SAS-CYOT/1.0` (logged) | | ||
| | `x-ms-correlation-id` | sign-in correlation id (fallback for envelope `correlationId`) | | ||
| | `x-ms-client-request-id` | per-attempt id (used as `messageId`) | | ||
|
|
||
| ### Request body — `SendCyotOtpRequest` (cleartext envelope) | ||
|
|
||
| | Field | Required | Notes | | ||
| |-------|----------|-------| | ||
| | `type` | ✅ | envelope contract version, e.g. `microsoft.mfa.otpDeliver.v1` | | ||
| | `tenantId` | | opaque routing guid (says nothing about the tenant) | | ||
| | `correlationId` | | sign-in correlation; stitches SAS ↔ provider traces | | ||
| | `channel` | ✅ | `CyotChannel` int: `1`=Sms, `2`=Voice (`0`=Undefined); the string forms `sms`/`voice` are also accepted | | ||
| | `mode` | ✅ | `CyotDeliveryMode` int: `1`=Live, `2`=Evaluation (rehearsal — do **NOT** deliver); the string forms `live`/`evaluation` are also accepted | | ||
| | `ttlSeconds` | | passcode validity remaining; a Live request with `ttlSeconds <= 0` is rejected (`400`) without dispatching | | ||
| | `encryptedDeliveryContext` | ✅ | JWE compact serialization (see below) | | ||
|
|
||
| `channel` not in `{1,2}`/`{sms,voice}` → `400`. `mode` not in `{1,2}`/`{live,evaluation}` → `400`. Missing/empty `encryptedDeliveryContext` → `400`. | ||
|
|
||
| ### `encryptedDeliveryContext` (JWE) | ||
|
|
||
| Alg: **RSA-OAEP-256** (CEK wrap) + **A256GCM** (content). The JOSE protected header carries `kid`; the | ||
| endpoint resolves the matching RSA private key (Key Vault secret, or `CYOT_JWE_PRIVATE_KEY_PEM` for | ||
| local dev) and decrypts. The compact JWE must have **exactly five non-empty segments** and stay within a | ||
| size limit; `alg`/`enc` are pinned (only `RSA-OAEP-256` + `A256GCM` accepted) and the AES-GCM auth tag is | ||
| verified before any plaintext is used. Decrypted plaintext = `CyotDeliveryContext`: | ||
|
|
||
| | Field | Required | Notes | | ||
| |-------|----------|-------| | ||
| | `nonce` | ✅ | value the endpoint MUST echo to prove decryption | | ||
| | `phoneNumber` | ✅ | E.164, single canonical string | | ||
| | `message` | ✅ | fully rendered + localized text; **contains the passcode** | | ||
| | `extension` | | office voice only | | ||
| | `locale` | | selects TTS voice for the voice channel | | ||
| | `riskContext` | | `CyotRiskContext` (scenario, familiarity flags, ip/asn/geo, ja4/ja4h, …) | | ||
|
|
||
| Decryption failure → `400`. Missing `nonce` / `phoneNumber` / `message` → `400`. | ||
|
|
||
| ### Response — `CyotEndpointResponse` (JSON) | ||
|
|
||
| ```json | ||
| { "nonce": "<echo of request nonce>", "correlationId": "<echo>", "providerStatus": "accepted" } | ||
| ``` | ||
|
|
||
| `accepted`/`pending` are **not** failures (provider queued it; acceptance ≠ delivery to the handset). | ||
| The endpoint returns **`202 Accepted`** on acceptance. On `2xx` **with a matching nonce**, SAS treats the | ||
| send as handled. **Nonce mismatch / non-2xx / timeout → SAS falls back to native CAPP delivery.** | ||
| `Evaluation` mode returns `202` + nonce echo without delivering. | ||
|
|
||
| --- | ||
|
|
||
| ## 2. Outcome → HTTP status mapping | ||
|
|
||
| The provider's parsed status is mapped via the adapter's `responseMapping` to an **outcome**, then to | ||
| an HTTP status. **Fail-closed:** an unknown/unmapped status is treated as `Fail`. | ||
|
|
||
| | Outcome | HTTP | When | | ||
| |---------|------|------| | ||
| | `Continue` | `202` | recognized success status (engine emits `200`; the endpoint returns `202 Accepted`) | | ||
| | `Block` | `403` | provider says blocked | | ||
| | `StepUp` | `409` | provider signals step-up / fraud escalation | | ||
| | `Fail` | `429` | provider returned 429 | | ||
| | `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) | | ||
|
|
||
| --- | ||
|
|
||
| ## 3. Provider adapter contract | ||
|
|
||
| Each provider is one unit exposing three things: | ||
|
|
||
| - **`manifest`** — protocol facts only: | ||
| - `id` — provider id (also the `Provider` value; endpoint app setting is `<ID>_ENDPOINT`) | ||
| - `auth` — `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }` or `{ mode: 'oauth2' }` | ||
| - `responseMapping` — map of provider status → `Continue` | `Fail` | `Block` | `StepUp` (+ `default`) | ||
| - **`buildRequest({ channel, endpoint, dispatch, credential, env })`** → `{ url, method, headers, body }` | ||
| - **`parseResponse({ httpStatus, ok, json })`** → `{ success, providerHttpStatus, providerMessageId, | ||
| providerStatusName | providerStatusCode, providerStatusDescription }` | ||
|
|
||
| The engine auto-discovers adapters (a `providers/` folder or registration). Endpoints, senders, TTLs, | ||
| etc. are **not** in the manifest — they are app settings (see §4). | ||
|
|
||
| --- | ||
|
|
||
| ## 4. Configuration (app settings / env) | ||
|
|
||
| Set by provisioning. **Identical names across all languages.** | ||
|
|
||
| | Key | Purpose | | ||
| |-----|---------| | ||
| | `DEFAULT_PROVIDER` | active provider id | | ||
| | `<ID>_ENDPOINT`, `<ID>_ENDPOINT_EUDB` | provider base URL (EUDB variant when `EUDB=true`) | | ||
| | `EUDB` | `true` → use EU endpoints | | ||
| | `ENDPOINT_TIMEOUT_MS` | outbound call timeout (default 1500) | | ||
| | `KEY_VAULT_URL` | Key Vault URI | | ||
| | `JWE_PRIVATE_KEY_SECRET` | Key Vault secret name holding the RSA private key PEM for JWE decryption (defaults to the JOSE `kid`) | | ||
| | `CYOT_JWE_PRIVATE_KEY_PEM` | inline RSA private key PEM for local dev (bypasses Key Vault) | | ||
| | `REQUIRE_AUTH` | `true` → enforce Entra token validation | | ||
| | `EXPECTED_AUDIENCE`, `ISSUER_TENANT_ID` | token validation (aud + issuer tenant) | | ||
| | `AZURE_CLIENT_ID` | set for a user-assigned managed identity | | ||
| | provider-specific | sender/source/voice IDs (e.g. `INFOBIP_SENDER_ID`, `SOPRANO_SOURCE_ID`) | | ||
|
|
||
| **Secrets** (provider API keys, identity secrets like customer/api ids) live in **Key Vault**, referenced | ||
| by name in the manifest and fetched at runtime via **managed identity** (needs the *Key Vault Secrets | ||
| User* role). Never in code or config. | ||
|
|
||
| --- | ||
|
|
||
| ## 5. Required behaviors | ||
|
|
||
| - **Fail-closed** — only `Continue` → `202 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** — the OTP code and phone number must **never** appear in logs or the response body (they | ||
| appear only in the outbound provider request, which is the delivery itself). | ||
| - **Auth** — when `REQUIRE_AUTH=true`, validate the Entra JWT (audience = `EXPECTED_AUDIENCE`, issuer | ||
| tenant = `ISSUER_TENANT_ID`, RS256, JWKS). No-op pass-through when false (local dev). | ||
|
|
||
| --- | ||
|
|
||
| ## 6. Conformance test scenarios | ||
|
|
||
| Every implementation ships tests covering at least: | ||
|
|
||
| 1. Each provider builds an HTTPS request with the code present and the correct auth scheme. | ||
| 2. `Block` → 403; provider 4xx `Fail` → 400; 429 → 429; 401/403 → 401. | ||
| 3. Provider HTTP 200 with an **unknown** status still `Fail`s (fail-closed). | ||
| 4. Missing provider credential → 502; missing endpoint config → 502. | ||
| 5. Timeout → 504; network error → 502. | ||
| 6. Envelope validation: `400` on invalid JSON, unsupported `channel`, unsupported `mode`, missing | ||
| `encryptedDeliveryContext`, decryption failure, and an incomplete delivery context. | ||
| 7. JWE round-trip: a context encrypted with RSA-OAEP-256 + A256GCM decrypts to the expected | ||
| `nonce` / `phoneNumber` / `message`, and the response echoes the `nonce`. | ||
| 8. `Evaluation` mode → 200 + nonce echo, nothing sent. | ||
| 9. Privacy: OTP code and phone never in logs or response body. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| # Customer Onboarding | ||
|
|
||
| A high-level guide to setting up, securing, and deploying the CYOT OTP Function. The steps are the same | ||
| for every language (`javascript/`, `dotnet/`, `python/`); only the build/run commands differ (see each | ||
| language's README). All config keys, Key Vault secret names, and behaviors are defined once in | ||
| [CONTRACT.md](CONTRACT.md). | ||
|
|
||
| ## 1. Pick a language and a provider | ||
|
|
||
| Choose an implementation folder and the SMS/voice provider you have an account with (Infobip, | ||
| Telesign, Soprano, Sinch). One provider is active per deployment. | ||
|
|
||
| ## 2. Store the provider secret in Key Vault | ||
|
|
||
| Provider API keys never live in code or app settings — put them in **Key Vault** under the names the | ||
| adapter expects (see [CONTRACT.md §3](CONTRACT.md)). The Function reads them at runtime via its | ||
| **managed identity**, which needs the *Key Vault Secrets User* role on the vault. | ||
|
|
||
| ## 3. Configure | ||
|
|
||
| Set the app settings from [`local.settings.sample.json`](local.settings.sample.json) — locally in a | ||
| `local.settings.json` file, in Azure as environment variables. The keys are identical across languages; | ||
| the full catalog is in [CONTRACT.md §4](CONTRACT.md). | ||
|
|
||
| ## 4. Run and send a test | ||
|
|
||
| Build/run per the language README, then `POST /api/SendOtp` with the cleartext envelope (the PII lives | ||
| in the encrypted JWE — see [CONTRACT.md](CONTRACT.md)). A **`202`** with the echoed `nonce` | ||
| (`{ "nonce": "<echo>", "correlationId": "<echo>", "providerStatus": "accepted" }`) means the provider | ||
| **queued** it — delivery is asynchronous, so confirm via the provider's delivery report. | ||
|
|
||
| ## 5. Secure it — `REQUIRE_AUTH` | ||
|
|
||
| Keep `REQUIRE_AUTH=false` for local development. For any real deployment, set **`REQUIRE_AUTH=true`** | ||
| (plus `EXPECTED_AUDIENCE` and `ISSUER_TENANT_ID`). The Function then validates the caller's **Entra | ||
| JWT** and returns **401** without a valid token. To test it, obtain a token for the expected audience | ||
| and confirm: no token → 401, valid token → 202. | ||
|
|
||
| ## 6. Deploy | ||
|
|
||
| Publish the chosen language folder to a Function App (see its README). Ensure the app's managed | ||
| identity has Key Vault access and the same environment variables are set. | ||
|
|
||
| ## 7. Add another provider | ||
|
|
||
| One adapter file — `manifest` + `buildRequest` + `parseResponse` — then store its secret in Key Vault | ||
| and set its endpoint app setting. No engine changes. See [CONTRACT.md §3](CONTRACT.md). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| { | ||
| "_comment": "Reference app settings for any implementation. Copy the Values into your local.settings.json (set FUNCTIONS_WORKER_RUNTIME to 'node' or 'dotnet-isolated') or into the Function App's environment variables. Keys are the same across languages — see CONTRACT.md §4. API keys are NOT here; they live in Key Vault.", | ||
| "IsEncrypted": false, | ||
| "Values": { | ||
| "FUNCTIONS_WORKER_RUNTIME": "node | dotnet-isolated", | ||
|
Copilot marked this conversation as resolved.
Outdated
|
||
|
|
||
| "DEFAULT_PROVIDER": "<infobip | telesign | sinch | soprano>", | ||
| "KEY_VAULT_URL": "https://<your-key-vault>.vault.azure.net/", | ||
| "EUDB": "false", | ||
| "ENDPOINT_TIMEOUT_MS": "1500", | ||
|
|
||
| "REQUIRE_AUTH": "false", | ||
| "EXPECTED_AUDIENCE": "<app registration the caller requests a token for>", | ||
| "ISSUER_TENANT_ID": "<your Entra tenant id>", | ||
|
|
||
| "<PROVIDER>_ENDPOINT": "https://<provider-base-url>", | ||
| "<PROVIDER>_ENDPOINT_EUDB": "https://<provider-eu-base-url>", | ||
| "<PROVIDER>_SENDER_ID": "<your sender / source id>" | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.