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
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
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
31 changes: 31 additions & 0 deletions .gitignore
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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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
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).
163 changes: 163 additions & 0 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# External Phone Provider 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.

> **Naming.** "CYOT" (Choose Your Own Telecom) is the internal code name for this feature. It still
> appears in wire-level identifiers that must not change — type names (`SendCyotOtpRequest`,
> `CyotDeliveryContext`), the `CYOT_JWE_PRIVATE_KEY_PEM` app setting, and the caller's `User-Agent`.

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 → 202 + nonce echo, nothing sent.
9. Privacy: OTP code and phone never in logs or response body.
47 changes: 47 additions & 0 deletions docs/ONBOARDING.md
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 External Phone Provider 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).
20 changes: 20 additions & 0 deletions docs/local.settings.sample.json
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', 'dotnet-isolated', or 'python') 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 | python",

"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>"
}
}
Loading
Loading