|
| 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. |
0 commit comments