From 2c3e88385e7d1074382c443dd05dbb70d0c7ad44 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Mon, 17 Aug 2026 11:38:20 -0700 Subject: [PATCH 01/17] Add CYOT OTP-delivery Azure Function sample (JavaScript, .NET, Python) Provider-agnostic OTP-delivery Azure Function in three languages (Node v4, .NET 8 isolated, Python v2) sharing one contract (docs/CONTRACT.md): one dispatch engine + drop-in provider adapters (Infobip, Telesign, Soprano, Sinch), Entra JWT validation, JWE delivery context (RSA-OAEP-256 + A256GCM) with structural/size validation, Key Vault secrets via managed identity, 202 Accepted with verbatim nonce echo, and conformance tests. --- .github/workflows/ci.yml | 53 ++ .gitignore | 32 + README.md | 50 ++ docs/CONTRACT.md | 159 +++++ docs/ONBOARDING.md | 47 ++ docs/local.settings.sample.json | 20 + dotnet/.gitignore | 264 +++++++ dotnet/Functions/SendOtp.cs | 118 ++++ dotnet/Program.cs | 28 + dotnet/Properties/launchSettings.json | 9 + dotnet/README.md | 47 ++ dotnet/Src/CyotEnvelope.cs | 68 ++ dotnet/Src/DeliveryContext.cs | 92 +++ dotnet/Src/DispatchEngine.cs | 146 ++++ dotnet/Src/IProviderAdapter.cs | 13 + dotnet/Src/ISecretResolver.cs | 7 + dotnet/Src/Models.cs | 41 ++ dotnet/Src/OutcomeMapper.cs | 32 + dotnet/Src/ProviderRegistry.cs | 27 + dotnet/Src/Providers/InfobipProvider.cs | 84 +++ dotnet/Src/Providers/SinchProvider.cs | 71 ++ dotnet/Src/Providers/SopranoProvider.cs | 98 +++ dotnet/Src/Providers/TelesignProvider.cs | 79 +++ dotnet/Src/SecretResolver.cs | 39 ++ dotnet/Src/TokenValidator.cs | 57 ++ dotnet/dotnet.csproj | 32 + dotnet/host.json | 4 + dotnet/tests/ContractTests.cs | 72 ++ dotnet/tests/Cyot.Otp.Tests.csproj | 28 + dotnet/tests/EngineTests.cs | 147 ++++ dotnet/tests/EnvelopeTests.cs | 78 +++ javascript/README.md | 196 ++++++ javascript/host.json | 15 + javascript/package-lock.json | 658 ++++++++++++++++++ javascript/package.json | 16 + javascript/src/functions/SendOtp.js | 99 +++ javascript/src/functions/cyot.js | 130 ++++ javascript/src/functions/dispatch.js | 387 ++++++++++ javascript/src/functions/providers/infobip.js | 71 ++ javascript/src/functions/providers/sinch.js | 70 ++ javascript/src/functions/providers/soprano.js | 90 +++ .../src/functions/providers/telesign.js | 89 +++ javascript/src/functions/security.js | 57 ++ javascript/test/auth.test.js | 45 ++ javascript/test/dispatch.test.js | 195 ++++++ javascript/test/security.test.js | 44 ++ javascript/test/sendotp.test.js | 155 +++++ python/README.md | 46 ++ python/function_app.py | 101 +++ python/host.json | 15 + python/requirements.txt | 9 + python/src/__init__.py | 0 python/src/cyot.py | 129 ++++ python/src/dispatch.py | 121 ++++ python/src/models.py | 18 + python/src/outcome.py | 30 + python/src/providers/__init__.py | 0 python/src/providers/infobip.py | 55 ++ python/src/providers/sinch.py | 51 ++ python/src/providers/soprano.py | 71 ++ python/src/providers/telesign.py | 63 ++ python/src/registry.py | 16 + python/src/secrets.py | 42 ++ python/src/security.py | 41 ++ python/tests/test_contract.py | 58 ++ python/tests/test_cyot.py | 78 +++ python/tests/test_engine.py | 134 ++++ scripts/provision-jwe-key.ps1 | 67 ++ scripts/test-nonce-echo.mjs | 120 ++++ 69 files changed, 5624 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/CONTRACT.md create mode 100644 docs/ONBOARDING.md create mode 100644 docs/local.settings.sample.json create mode 100644 dotnet/.gitignore create mode 100644 dotnet/Functions/SendOtp.cs create mode 100644 dotnet/Program.cs create mode 100644 dotnet/Properties/launchSettings.json create mode 100644 dotnet/README.md create mode 100644 dotnet/Src/CyotEnvelope.cs create mode 100644 dotnet/Src/DeliveryContext.cs create mode 100644 dotnet/Src/DispatchEngine.cs create mode 100644 dotnet/Src/IProviderAdapter.cs create mode 100644 dotnet/Src/ISecretResolver.cs create mode 100644 dotnet/Src/Models.cs create mode 100644 dotnet/Src/OutcomeMapper.cs create mode 100644 dotnet/Src/ProviderRegistry.cs create mode 100644 dotnet/Src/Providers/InfobipProvider.cs create mode 100644 dotnet/Src/Providers/SinchProvider.cs create mode 100644 dotnet/Src/Providers/SopranoProvider.cs create mode 100644 dotnet/Src/Providers/TelesignProvider.cs create mode 100644 dotnet/Src/SecretResolver.cs create mode 100644 dotnet/Src/TokenValidator.cs create mode 100644 dotnet/dotnet.csproj create mode 100644 dotnet/host.json create mode 100644 dotnet/tests/ContractTests.cs create mode 100644 dotnet/tests/Cyot.Otp.Tests.csproj create mode 100644 dotnet/tests/EngineTests.cs create mode 100644 dotnet/tests/EnvelopeTests.cs create mode 100644 javascript/README.md create mode 100644 javascript/host.json create mode 100644 javascript/package-lock.json create mode 100644 javascript/package.json create mode 100644 javascript/src/functions/SendOtp.js create mode 100644 javascript/src/functions/cyot.js create mode 100644 javascript/src/functions/dispatch.js create mode 100644 javascript/src/functions/providers/infobip.js create mode 100644 javascript/src/functions/providers/sinch.js create mode 100644 javascript/src/functions/providers/soprano.js create mode 100644 javascript/src/functions/providers/telesign.js create mode 100644 javascript/src/functions/security.js create mode 100644 javascript/test/auth.test.js create mode 100644 javascript/test/dispatch.test.js create mode 100644 javascript/test/security.test.js create mode 100644 javascript/test/sendotp.test.js create mode 100644 python/README.md create mode 100644 python/function_app.py create mode 100644 python/host.json create mode 100644 python/requirements.txt create mode 100644 python/src/__init__.py create mode 100644 python/src/cyot.py create mode 100644 python/src/dispatch.py create mode 100644 python/src/models.py create mode 100644 python/src/outcome.py create mode 100644 python/src/providers/__init__.py create mode 100644 python/src/providers/infobip.py create mode 100644 python/src/providers/sinch.py create mode 100644 python/src/providers/soprano.py create mode 100644 python/src/providers/telesign.py create mode 100644 python/src/registry.py create mode 100644 python/src/secrets.py create mode 100644 python/src/security.py create mode 100644 python/tests/test_contract.py create mode 100644 python/tests/test_cyot.py create mode 100644 python/tests/test_engine.py create mode 100644 scripts/provision-jwe-key.ps1 create mode 100644 scripts/test-nonce-echo.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eeb6305 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f282ea8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ + +# Python +.venv/ +venv/ +*.pyc +.pytest_cache/ + +# Azure Functions local settings — may contain secrets; never commit. +local.settings.json + +# Local JWE test keys / certificates — never commit private keys. +scripts/.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..38dcc12 --- /dev/null +++ b/README.md @@ -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). diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md new file mode 100644 index 0000000..6b4f686 --- /dev/null +++ b/docs/CONTRACT.md @@ -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 ` (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": "", "correlationId": "", "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 `_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 | +| `_ENDPOINT`, `_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. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md new file mode 100644 index 0000000..c0aae97 --- /dev/null +++ b/docs/ONBOARDING.md @@ -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": "", "correlationId": "", "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). diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json new file mode 100644 index 0000000..cca1520 --- /dev/null +++ b/docs/local.settings.sample.json @@ -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", + + "DEFAULT_PROVIDER": "", + "KEY_VAULT_URL": "https://.vault.azure.net/", + "EUDB": "false", + "ENDPOINT_TIMEOUT_MS": "1500", + + "REQUIRE_AUTH": "false", + "EXPECTED_AUDIENCE": "", + "ISSUER_TENANT_ID": "", + + "_ENDPOINT": "https://", + "_ENDPOINT_EUDB": "https://", + "_SENDER_ID": "" + } +} diff --git a/dotnet/.gitignore b/dotnet/.gitignore new file mode 100644 index 0000000..ff5b00c --- /dev/null +++ b/dotnet/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs new file mode 100644 index 0000000..95a5208 --- /dev/null +++ b/dotnet/Functions/SendOtp.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace Cyot.Otp; + +// HTTP trigger: POST /api/SendOtp — the SAS → CYOT delivery endpoint. Validates the Entra token, parses +// the cleartext routing envelope, decrypts the JWE delivery context (PII lives there), dispatches to the +// provider, and echoes the nonce to prove decryption. Privacy: phone and OTP code are never logged or +// returned; the response body is the minimal CyotEndpointResponse. +public sealed class SendOtp +{ + private readonly DispatchEngine _engine; + private readonly TokenValidator _tokens; + private readonly JweDecryptor _decryptor; + private readonly ILogger _log; + + public SendOtp(DispatchEngine engine, TokenValidator tokens, JweDecryptor decryptor, ILogger log) + { + _engine = engine; + _tokens = tokens; + _decryptor = decryptor; + _log = log; + } + + [Function("SendOtp")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "SendOtp")] HttpRequest req) + { + var requestId = Guid.NewGuid().ToString("n"); + var clientRequestId = req.Headers["x-ms-client-request-id"].FirstOrDefault() ?? requestId; + var headerCorrelationId = req.Headers["x-ms-correlation-id"].FirstOrDefault(); + + var auth = await _tokens.ValidateAsync(req.Headers.Authorization.FirstOrDefault()); + if (!auth.Ok) + { + _log.LogWarning("[AUTH_ERROR] requestId={RequestId} reason={Reason}", requestId, auth.Reason); + return new ObjectResult(new { error = "unauthorized", reason = auth.Reason, requestId }) { StatusCode = 401 }; + } + + JsonElement payload; + try + { + using var doc = await JsonDocument.ParseAsync(req.Body); + payload = doc.RootElement.Clone(); + } + catch + { + _log.LogWarning("[ERROR] requestId={RequestId} invalid JSON body", requestId); + return new BadRequestObjectResult(new { error = "bad_request", reason = "invalid JSON body", requestId }); + } + + var (envelope, envelopeError) = EnvelopeParser.Parse(payload); + if (envelopeError is not null) + { + _log.LogWarning("[VALIDATION_ERROR] requestId={RequestId} {Reason}", requestId, envelopeError); + return new BadRequestObjectResult(new { error = "bad_request", reason = envelopeError, requestId }); + } + + var correlationId = envelope!.CorrelationId ?? headerCorrelationId ?? requestId; + + CyotDeliveryContext context; + try + { + context = _decryptor.Decrypt(envelope.EncryptedDeliveryContext); + } + catch (Exception ex) + { + _log.LogWarning("[DECRYPT_ERROR] requestId={RequestId} correlationId={CorrelationId} reason={Reason}", requestId, correlationId, ex.Message); + return new ObjectResult(new { error = "decryption_failed", correlationId, requestId }) { StatusCode = 400 }; + } + + if (string.IsNullOrEmpty(context.Nonce) || string.IsNullOrEmpty(context.PhoneNumber) || string.IsNullOrEmpty(context.Message)) + { + _log.LogWarning("[VALIDATION_ERROR] requestId={RequestId} correlationId={CorrelationId} incomplete delivery context", requestId, correlationId); + return new ObjectResult(new { error = "bad_request", reason = "incomplete delivery context", correlationId, requestId }) { StatusCode = 400 }; + } + + var evaluation = envelope.Mode == EnvelopeParser.ModeEvaluation; + var channel = EnvelopeParser.ChannelName(envelope.Channel)!; + + // Respect ttlSeconds: don't start a live delivery for an already-expired passcode (contract §7). + if (!evaluation && envelope.TtlSeconds is <= 0) + { + _log.LogWarning("[EXPIRED] requestId={RequestId} correlationId={CorrelationId} ttl={Ttl}", requestId, correlationId, envelope.TtlSeconds); + return new ObjectResult(new { error = "request_expired", correlationId, requestId }) { StatusCode = 400 }; + } + + _log.LogInformation( + "[SENDOTP] requestId={RequestId} caller={Caller} type={Type} tenant={Tenant} correlationId={CorrelationId} channel={Channel} mode={Mode} ttl={Ttl} phone=present message=present risk={Risk}", + requestId, auth.CallerObjectId ?? "n/a", envelope.Type ?? "n/a", envelope.TenantId ?? "n/a", correlationId, + envelope.Channel, envelope.Mode, envelope.TtlSeconds?.ToString() ?? "n/a", context.RiskContext.HasValue ? "present" : "absent"); + + var dispatch = new DispatchRequest( + Destination: context.PhoneNumber!, + Message: context.Message, + Channel: channel, + MessageId: clientRequestId, + CorrelationId: correlationId, + Locale: context.Locale); + + try + { + var result = await _engine.DispatchAsync(dispatch, null, evaluation, requestId, _log); + // Contract: acceptance is 202 Accepted (async delivery); the engine signals acceptance as 200. + var accepted = result.HttpStatus == 200; + return new ObjectResult(new { nonce = context.Nonce, correlationId, providerStatus = accepted ? "accepted" : "failed" }) + { StatusCode = accepted ? 202 : result.HttpStatus }; + } + catch (Exception ex) + { + _log.LogError("[EXCEPTION] requestId={RequestId} error={Error}", requestId, ex.Message); + return new ObjectResult(new { nonce = context.Nonce, correlationId, providerStatus = "failed" }) { StatusCode = 500 }; + } + } +} diff --git a/dotnet/Program.cs b/dotnet/Program.cs new file mode 100644 index 0000000..d82586e --- /dev/null +++ b/dotnet/Program.cs @@ -0,0 +1,28 @@ +using Cyot.Otp; +using Cyot.Otp.Providers; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = FunctionsApplication.CreateBuilder(args); + +builder.ConfigureFunctionsWebApplication(); + +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Provider adapters — add one line to onboard a provider. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Build().Run(); diff --git a/dotnet/Properties/launchSettings.json b/dotnet/Properties/launchSettings.json new file mode 100644 index 0000000..9580a19 --- /dev/null +++ b/dotnet/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "dotnet": { + "commandName": "Project", + "commandLineArgs": "--port 7104", + "launchBrowser": false + } + } +} \ No newline at end of file diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 0000000..0c1e74e --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,47 @@ +# CYOT Function — C# (.NET isolated worker) + +A C# implementation of the CYOT OTP-delivery Function, conforming to the shared +[contract](../docs/CONTRACT.md). Same design as the [`javascript/`](../javascript/) version: +one dispatch engine + drop-in provider adapters, env-provisioned config, secrets in Key Vault. + +## Layout + +``` +dotnet/ +├─ Program.cs # host + DI registration (add one line to onboard a provider) +├─ Functions/SendOtp.cs # HTTP trigger: POST /api/SendOtp +├─ Src/ +│ ├─ CyotEnvelope.cs # parse/validate the cleartext SAS → CYOT envelope +│ ├─ DeliveryContext.cs # JWE decrypt (RSA-OAEP-256 + A256GCM) → CyotDeliveryContext +│ ├─ DispatchEngine.cs # resolve provider → credential → endpoint → send → outcome +│ ├─ ProviderRegistry.cs # keyed adapter registry + DEFAULT_PROVIDER resolution +│ ├─ IProviderAdapter.cs # Manifest + BuildRequest + ParseResponse +│ ├─ Providers/*.cs # infobip, telesign, soprano, sinch +│ ├─ SecretResolver.cs # Key Vault via managed identity (cached) +│ ├─ ISecretResolver.cs # secret-resolver abstraction (injectable for tests) +│ ├─ OutcomeMapper.cs # status → outcome → HTTP status +│ ├─ Models.cs # DispatchRequest + shared records +│ └─ TokenValidator.cs # Entra JWT validation when REQUIRE_AUTH=true +└─ tests/ # xUnit conformance tests +``` + +## Build, test, run + +```bash +cd dotnet +dotnet build # build the Functions app +dotnet test tests # run conformance tests +func start # run locally (copy ../docs/local.settings.sample.json to local.settings.json) +``` + +## Deploy + +```bash +func azure functionapp publish --dotnet-isolated +``` + +The app's **managed identity** needs the **Key Vault Secrets User** role on the vault. Configuration +(env var names, Key Vault secret names, behaviors) is identical to the contract — see +[`../docs/CONTRACT.md`](../docs/CONTRACT.md). + +Target: .NET 8 isolated worker, Functions v4. diff --git a/dotnet/Src/CyotEnvelope.cs b/dotnet/Src/CyotEnvelope.cs new file mode 100644 index 0000000..8e25e03 --- /dev/null +++ b/dotnet/Src/CyotEnvelope.cs @@ -0,0 +1,68 @@ +using System.Text.Json; + +namespace Cyot.Otp; + +// The cleartext SAS → CYOT routing envelope (SendCyotOtpRequest). PII lives in the encrypted JWE. +public sealed record CyotEnvelope( + string? Type, + string? TenantId, + string? CorrelationId, + int Channel, + int Mode, + int? TtlSeconds, + string EncryptedDeliveryContext); + +// Parses + validates the cleartext envelope (see docs/CONTRACT.md §1). +public static class EnvelopeParser +{ + public const int ModeLive = 1; + public const int ModeEvaluation = 2; + + private static readonly Dictionary ChannelByCode = new() { [1] = "sms", [2] = "voice" }; + private static readonly Dictionary ChannelByName = new(StringComparer.OrdinalIgnoreCase) { ["sms"] = 1, ["voice"] = 2 }; + private static readonly Dictionary ModeByName = new(StringComparer.OrdinalIgnoreCase) { ["live"] = ModeLive, ["evaluation"] = ModeEvaluation }; + + public static string? ChannelName(int code) => ChannelByCode.TryGetValue(code, out var name) ? name : null; + + public static (CyotEnvelope? Envelope, string? Error) Parse(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) + return (null, "invalid envelope"); + + string? String(string name) => + payload.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + int? Int(string name) => + payload.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null; + + // channel/mode accept the int enum (1/2) or the string form ("sms"/"voice", "live"/"evaluation"). + int? Channel() + { + var code = Int("channel"); + if (code is not null) return ChannelByCode.ContainsKey(code.Value) ? code : null; + var name = String("channel"); + return name is not null && ChannelByName.TryGetValue(name, out var mapped) ? mapped : null; + } + int? Mode() + { + var code = Int("mode"); + if (code is not null) return code is ModeLive or ModeEvaluation ? code : null; + var name = String("mode"); + return name is not null && ModeByName.TryGetValue(name, out var mapped) ? mapped : null; + } + + var encrypted = String("encryptedDeliveryContext"); + if (string.IsNullOrEmpty(encrypted)) + return (null, "encryptedDeliveryContext is required"); + + var channel = Channel(); + if (channel is null) + return (null, "unsupported channel"); + + var mode = Mode(); + if (mode is null) + return (null, "unsupported mode"); + + return (new CyotEnvelope(String("type"), String("tenantId"), String("correlationId"), + channel.Value, mode.Value, Int("ttlSeconds"), encrypted), null); + } +} diff --git a/dotnet/Src/DeliveryContext.cs b/dotnet/Src/DeliveryContext.cs new file mode 100644 index 0000000..c973d13 --- /dev/null +++ b/dotnet/Src/DeliveryContext.cs @@ -0,0 +1,92 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cyot.Otp; + +// Decrypted JWE plaintext (CyotDeliveryContext). Contains the PII: phone + the rendered message, +// which includes the passcode. Never logged. +public sealed class CyotDeliveryContext +{ + [JsonPropertyName("nonce")] public string? Nonce { get; set; } + [JsonPropertyName("phoneNumber")] public string? PhoneNumber { get; set; } + [JsonPropertyName("extension")] public string? Extension { get; set; } + [JsonPropertyName("locale")] public string? Locale { get; set; } + [JsonPropertyName("message")] public string? Message { get; set; } + [JsonPropertyName("riskContext")] public JsonElement? RiskContext { get; set; } +} + +// Supplies the RSA private key for the JWE `kid`. Injectable so tests use a local key, not Key Vault. +public interface IJweKeyProvider +{ + RSA GetPrivateKey(string? kid); +} + +// Decrypts the RSA-OAEP-256 + A256GCM JWE compact serialization to a CyotDeliveryContext. +public sealed class JweDecryptor +{ + private readonly IJweKeyProvider _keys; + + public JweDecryptor(IJweKeyProvider keys) => _keys = keys; + + // Reject oversized or structurally invalid JWEs before base64-decoding or allocating buffers. + private const int MaxJweLength = 16384; + + public CyotDeliveryContext Decrypt(string compactJwe) + { + AssertWellFormed(compactJwe); + var kid = ReadKid(compactJwe); + var rsa = _keys.GetPrivateKey(kid); + // Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). + var plaintext = Jose.JWT.Decrypt(compactJwe, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); + return JsonSerializer.Deserialize(plaintext) ?? new CyotDeliveryContext(); + } + + // Contract: exactly five non-empty compact segments; alg/enc/IV/tag are enforced by the JWE decrypt. + private static void AssertWellFormed(string compactJwe) + { + if (string.IsNullOrEmpty(compactJwe)) + throw new InvalidOperationException("malformed JWE"); + if (compactJwe.Length > MaxJweLength) + throw new InvalidOperationException("delivery context exceeds size limit"); + var segments = compactJwe.Split('.'); + if (segments.Length != 5 || Array.Exists(segments, string.IsNullOrEmpty)) + throw new InvalidOperationException("malformed JWE: expected five non-empty segments"); + } + + public static string? ReadKid(string compactJwe) + { + var headers = Jose.JWT.Headers(compactJwe); + return headers.TryGetValue("kid", out var kid) ? kid?.ToString() : null; + } +} + +// Default key source: an inline PEM (CYOT_JWE_PRIVATE_KEY_PEM, local/dev) or a Key Vault secret +// (name = JWE_PRIVATE_KEY_SECRET, else the `kid`). +public sealed class KeyVaultJweKeyProvider : IJweKeyProvider +{ + private readonly ISecretResolver _secrets; + private readonly IEnv _env; + + public KeyVaultJweKeyProvider(ISecretResolver secrets, IEnv env) + { + _secrets = secrets; + _env = env; + } + + public RSA GetPrivateKey(string? kid) + { + var pem = _env.Get("CYOT_JWE_PRIVATE_KEY_PEM"); + if (string.IsNullOrEmpty(pem)) + { + var secretName = _env.Get("JWE_PRIVATE_KEY_SECRET") ?? kid; + pem = string.IsNullOrEmpty(secretName) ? null : _secrets.ResolveAsync(secretName).GetAwaiter().GetResult(); + } + if (string.IsNullOrEmpty(pem)) + throw new InvalidOperationException("private key unavailable"); + + var rsa = RSA.Create(); + rsa.ImportFromPem(pem); + return rsa; + } +} diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs new file mode 100644 index 0000000..e158695 --- /dev/null +++ b/dotnet/Src/DispatchEngine.cs @@ -0,0 +1,146 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Cyot.Otp; + +// Core engine: resolve provider -> credential (Key Vault) -> endpoint -> adapter builds request -> +// send with a timeout -> map status to outcome + HTTP status. Fail-closed. +public sealed class DispatchEngine +{ + private const int DefaultTimeoutMs = 1500; + private readonly ProviderRegistry _registry; + private readonly ISecretResolver _secrets; + private readonly IHttpClientFactory _httpFactory; + private readonly IEnv _env; + + public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null) + { + _registry = registry; + _secrets = secrets; + _httpFactory = httpFactory; + _env = env ?? new ProcessEnv(); + } + + public async Task DispatchAsync(DispatchRequest dispatch, string? requestProvider, bool shutter, string requestId, ILogger log) + { + var adapter = _registry.Resolve(requestProvider); + if (adapter is null) + { + log.LogWarning("[DISPATCH_ERROR] requestId={RequestId} unknown provider={Provider}", requestId, requestProvider ?? "n/a"); + return new DispatchResult(400, new { status = "error", reason = "unknown provider", requestId }); + } + + var manifest = adapter.Manifest; + var providerId = manifest.Id; + var channel = (dispatch.Channel ?? "sms").ToLowerInvariant(); + + if (!OutcomeMapper.DefaultChannels.Contains(channel)) + return new DispatchResult(400, new { status = "error", provider = providerId, reason = $"channel '{channel}' not supported", requestId }); + + // Credential (fail closed 502 if missing) — this is our credential, not the caller's token. + ProviderCredential? credential = null; + try { credential = await ResolveCredentialAsync(manifest.Auth); } + catch (Exception ex) { log.LogError("[DISPATCH_ERROR] requestId={RequestId} provider={Provider} credential error={Error}", requestId, providerId, ex.Message); } + + var identityRequired = credential is { Mode: "apiKey" } && !string.IsNullOrEmpty(manifest.Auth.IdentityKeyVaultSecretName); + var credentialUnavailable = credential is null + || (credential.Mode == "oauth2" && string.IsNullOrEmpty(credential.Token)) + || (credential.Mode == "apiKey" && string.IsNullOrEmpty(credential.Secret)) + || (identityRequired && string.IsNullOrEmpty(credential.Identity)); + if (credentialUnavailable) + return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); + + var endpoint = ResolveEndpoint(manifest, _env); + if (string.IsNullOrEmpty(endpoint)) + return new DispatchResult(502, FailBody(providerId, channel, "provider endpoint not configured", dispatch, requestId)); + + var req = adapter.BuildRequest(channel, endpoint, dispatch, credential!, _env); + log.LogInformation("[DISPATCH] requestId={RequestId} provider={Provider} channel={Channel} shutter={Shutter}", requestId, providerId, channel, shutter); + + if (shutter) + return new DispatchResult(200, new { status = "accepted", shutterProcessed = true, provider = providerId, channel, correlationId = dispatch.CorrelationId, messageId = dispatch.MessageId, requestId }); + + var timeoutMs = int.TryParse(_env.Get("ENDPOINT_TIMEOUT_MS"), out var parsedTimeout) ? parsedTimeout : DefaultTimeoutMs; + HttpResponseMessage resp; + string body; + try + { + (resp, body) = await SendAsync(req, timeoutMs); + } + catch (OperationCanceledException) + { + log.LogWarning("[DISPATCH_TIMEOUT] requestId={RequestId} provider={Provider}", requestId, providerId); + return new DispatchResult(504, FailBody(providerId, channel, $"endpoint timeout after {timeoutMs}ms", dispatch, requestId)); + } + catch (Exception ex) + { + log.LogError("[DISPATCH_ERROR] requestId={RequestId} provider={Provider} reason={Reason}", requestId, providerId, ex.Message); + return new DispatchResult(502, FailBody(providerId, channel, ex.Message, dispatch, requestId)); + } + + JsonElement json; + try { using var responseDocument = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); json = responseDocument.RootElement.Clone(); } + catch { using var emptyDocument = JsonDocument.Parse("{}"); json = emptyDocument.RootElement.Clone(); } + + var parsed = adapter.ParseResponse((int)resp.StatusCode, resp.IsSuccessStatusCode, json); + var outcome = OutcomeMapper.ResolveOutcome(manifest, parsed); + var httpStatus = OutcomeMapper.ToHttpStatus(outcome, parsed.ProviderHttpStatus); + + log.LogInformation("[DISPATCH_RESULT] requestId={RequestId} provider={Provider} channel={Channel} outcome={Outcome} providerStatus={Status} httpStatus={Http}", + requestId, providerId, channel, outcome, parsed.ProviderStatusName ?? parsed.ProviderStatusCode ?? "n/a", httpStatus); + + return new DispatchResult(httpStatus, new + { + status = outcome == Outcome.Continue ? "accepted" : "failed", + outcome = outcome.ToString(), + provider = providerId, + channel, + messageId = dispatch.MessageId, + correlationId = dispatch.CorrelationId, + providerMessageId = parsed.ProviderMessageId, + providerStatus = parsed.ProviderStatusName ?? parsed.ProviderStatusCode, + providerStatusDescription = parsed.ProviderStatusDescription, + requestId, + }); + } + + private async Task ResolveCredentialAsync(AuthConfig auth) + { + if (auth.Mode == "oauth2") return new ProviderCredential("oauth2", Token: null); // not wired -> fails closed + var secret = await _secrets.ResolveAsync(auth.KeyVaultSecretName); + var identity = string.IsNullOrEmpty(auth.IdentityKeyVaultSecretName) ? string.Empty : await _secrets.ResolveAsync(auth.IdentityKeyVaultSecretName); + return new ProviderCredential("apiKey", Secret: secret, Identity: identity); + } + + // Base URL from app settings: _ENDPOINT_EUDB when EUDB=true, else _ENDPOINT. + private static string? ResolveEndpoint(ProviderManifest manifest, IEnv env) + { + var idUpper = manifest.Id.ToUpperInvariant(); + var useEudb = string.Equals(env.Get("EUDB"), "true", StringComparison.OrdinalIgnoreCase); + var eudb = env.Get($"{idUpper}_ENDPOINT_EUDB"); + if (useEudb && !string.IsNullOrEmpty(eudb)) return eudb; + return env.Get($"{idUpper}_ENDPOINT"); + } + + private async Task<(HttpResponseMessage, string)> SendAsync(ProviderHttpRequest req, int timeoutMs) + { + using var cts = new CancellationTokenSource(timeoutMs); + var client = _httpFactory.CreateClient(); + using var message = new HttpRequestMessage(new HttpMethod(req.Method), req.Url) + { + Content = new StringContent(req.Body, Encoding.UTF8, req.Headers.TryGetValue("Content-Type", out var ct) ? ct : "application/json"), + }; + foreach (var (k, v) in req.Headers) + { + if (k.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) continue; + if (!message.Headers.TryAddWithoutValidation(k, v)) message.Content.Headers.TryAddWithoutValidation(k, v); + } + var resp = await client.SendAsync(message, cts.Token); + var body = await resp.Content.ReadAsStringAsync(cts.Token); + return (resp, body); + } + + private static object FailBody(string provider, string channel, string reason, DispatchRequest d, string requestId) => + new { status = "failed", outcome = "Fail", provider, channel, reason, correlationId = d.CorrelationId, messageId = d.MessageId, requestId }; +} diff --git a/dotnet/Src/IProviderAdapter.cs b/dotnet/Src/IProviderAdapter.cs new file mode 100644 index 0000000..6b3d239 --- /dev/null +++ b/dotnet/Src/IProviderAdapter.cs @@ -0,0 +1,13 @@ +using System.Text.Json; + +namespace Cyot.Otp; + +// A provider is one adapter: manifest (protocol facts) + build/parse. Onboarding = add one class. +public interface IProviderAdapter +{ + ProviderManifest Manifest { get; } + + ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env); + + ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json); +} diff --git a/dotnet/Src/ISecretResolver.cs b/dotnet/Src/ISecretResolver.cs new file mode 100644 index 0000000..a924e6b --- /dev/null +++ b/dotnet/Src/ISecretResolver.cs @@ -0,0 +1,7 @@ +namespace Cyot.Otp; + +// Seam over Key Vault so the engine can be unit-tested with a fake. +public interface ISecretResolver +{ + Task ResolveAsync(string? secretName); +} diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs new file mode 100644 index 0000000..cc925e8 --- /dev/null +++ b/dotnet/Src/Models.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace Cyot.Otp; + +// Language-agnostic contract types (see /docs/CONTRACT.md). + +public enum Outcome { Continue, Fail, Block, StepUp } + +public sealed record DispatchRequest( + string Destination, + string? Message, + string Channel, + string MessageId, + string? CorrelationId, + string? Locale); + +public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, string? Token = null); + +public sealed record ProviderHttpRequest(string Url, string Method, Dictionary Headers, string Body); + +public sealed record ParsedResponse( + bool Success, + int ProviderHttpStatus, + string? ProviderMessageId = null, + string? ProviderStatusName = null, + string? ProviderStatusCode = null, + string? ProviderStatusDescription = null); + +public sealed record AuthConfig(string Mode, string? KeyVaultSecretName = null, string? IdentityKeyVaultSecretName = null); + +public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDictionary ResponseMapping); + +public sealed record DispatchResult(int HttpStatus, object Body); + +// The env snapshot passed to adapters (mirrors the JS `env` argument). +public interface IEnv { string? Get(string key); } + +public sealed class ProcessEnv : IEnv +{ + public string? Get(string key) => Environment.GetEnvironmentVariable(key); +} diff --git a/dotnet/Src/OutcomeMapper.cs b/dotnet/Src/OutcomeMapper.cs new file mode 100644 index 0000000..be750df --- /dev/null +++ b/dotnet/Src/OutcomeMapper.cs @@ -0,0 +1,32 @@ +namespace Cyot.Otp; + +// Maps a provider's parsed status to a normalized outcome, then to an HTTP status. Fail-closed: +// an unknown/unmapped status is treated as Fail. +public static class OutcomeMapper +{ + public static readonly string[] DefaultChannels = { "sms", "voice" }; + + public static Outcome ResolveOutcome(ProviderManifest manifest, ParsedResponse parsed) + { + var key = parsed.ProviderStatusName ?? parsed.ProviderStatusCode; + if (!string.IsNullOrEmpty(key)) + { + if (manifest.ResponseMapping.TryGetValue(key, out var mapped)) return mapped; + return manifest.ResponseMapping.TryGetValue("default", out var defaultOutcome) ? defaultOutcome : Outcome.Fail; + } + if (parsed.Success) return Outcome.Continue; + return manifest.ResponseMapping.TryGetValue("default", out var fallbackOutcome) ? fallbackOutcome : Outcome.Fail; + } + + // Continue 200, Block 403, StepUp 409; a Fail surfaces the provider's failure class. + public static int ToHttpStatus(Outcome outcome, int providerHttpStatus) => outcome switch + { + Outcome.Continue => 200, + Outcome.Block => 403, + Outcome.StepUp => 409, + Outcome.Fail when providerHttpStatus == 429 => 429, + Outcome.Fail when providerHttpStatus is 401 or 403 => 401, + Outcome.Fail when providerHttpStatus >= 400 && providerHttpStatus < 500 => 400, + _ => 502, + }; +} diff --git a/dotnet/Src/ProviderRegistry.cs b/dotnet/Src/ProviderRegistry.cs new file mode 100644 index 0000000..eea0865 --- /dev/null +++ b/dotnet/Src/ProviderRegistry.cs @@ -0,0 +1,27 @@ +namespace Cyot.Otp; + +// Registry of provider adapters (keyed by lowercased id), and resolution of the active provider: +// the request's Provider, else the deployment's DEFAULT_PROVIDER. One provider active per deployment. +public sealed class ProviderRegistry +{ + private readonly IReadOnlyDictionary _byId; + + public ProviderRegistry(IEnumerable adapters) + { + _byId = adapters.ToDictionary(a => a.Manifest.Id.ToLowerInvariant(), a => a); + } + + public IProviderAdapter? Get(string? id) + { + if (string.IsNullOrWhiteSpace(id)) return null; + return _byId.TryGetValue(id.ToLowerInvariant(), out var adapter) ? adapter : null; + } + + public IProviderAdapter? Resolve(string? requestProvider) + { + var id = !string.IsNullOrWhiteSpace(requestProvider) + ? requestProvider + : Environment.GetEnvironmentVariable("DEFAULT_PROVIDER"); + return Get(id); + } +} diff --git a/dotnet/Src/Providers/InfobipProvider.cs b/dotnet/Src/Providers/InfobipProvider.cs new file mode 100644 index 0000000..38573b6 --- /dev/null +++ b/dotnet/Src/Providers/InfobipProvider.cs @@ -0,0 +1,84 @@ +using System.Text.Json; + +namespace Cyot.Otp.Providers; + +// Infobip: SMS via /sms/3/messages, voice via /tts/3/advanced. Auth: App API key. +public sealed class InfobipProvider : IProviderAdapter +{ + public ProviderManifest Manifest { get; } = new( + Id: "infobip", + Auth: new AuthConfig("apiKey", KeyVaultSecretName: "infobip-api-key"), + ResponseMapping: new Dictionary + { + ["ACCEPTED"] = Outcome.Continue, + ["PENDING"] = Outcome.Continue, + ["DELIVERED"] = Outcome.Continue, + ["REJECTED"] = Outcome.Fail, + ["EXPIRED"] = Outcome.Fail, + ["UNDELIVERABLE"] = Outcome.Fail, + ["default"] = Outcome.Fail, + }); + + public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) + { + var senderId = env.Get("INFOBIP_SENDER_ID") ?? "Verify"; + var auth = credential.Mode == "oauth2" ? $"Bearer {credential.Token}" : $"App {credential.Secret}"; + var headers = new Dictionary + { + ["Authorization"] = auth, + ["Content-Type"] = "application/json", + ["Accept"] = "application/json", + }; + var messageId = dispatch.CorrelationId ?? dispatch.MessageId; + + if (channel == "voice") + { + var voiceBody = new + { + messages = new[] + { + new + { + from = env.Get("INFOBIP_VOICE_FROM") ?? senderId, + destinations = new[] { new { to = dispatch.Destination, messageId } }, + text = dispatch.Message, + language = dispatch.Locale ?? "en", + voice = new { name = "Joanna", gender = "female" }, + }, + }, + }; + return new ProviderHttpRequest($"{endpoint}/tts/3/advanced", "POST", headers, JsonSerializer.Serialize(voiceBody)); + } + + var body = new + { + messages = new[] + { + new + { + sender = senderId, + destinations = new[] { new { to = dispatch.Destination, messageId } }, + content = new { text = dispatch.Message }, + }, + }, + }; + return new ProviderHttpRequest($"{endpoint}/sms/3/messages", "POST", headers, JsonSerializer.Serialize(body)); + } + + public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) + { + string? messageId = null, statusName = null, statusDesc = null; + if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("messages", out var messages) && messages.ValueKind == JsonValueKind.Array && messages.GetArrayLength() > 0) + { + var firstMessage = messages[0]; + if (firstMessage.TryGetProperty("messageId", out var messageIdElement)) messageId = messageIdElement.ToString(); + if (firstMessage.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Object) + { + if (status.TryGetProperty("groupName", out var groupName)) statusName = groupName.GetString()?.ToUpperInvariant(); + else if (status.TryGetProperty("name", out var name)) statusName = name.GetString()?.ToUpperInvariant(); + if (status.TryGetProperty("description", out var description)) statusDesc = description.GetString(); + } + } + return new ParsedResponse(ok, httpStatus, messageId, statusName, null, statusDesc); + } +} diff --git a/dotnet/Src/Providers/SinchProvider.cs b/dotnet/Src/Providers/SinchProvider.cs new file mode 100644 index 0000000..9075da2 --- /dev/null +++ b/dotnet/Src/Providers/SinchProvider.cs @@ -0,0 +1,71 @@ +using System.Text.Json; + +namespace Cyot.Otp.Providers; + +// Sinch: SMS via XMS Batches (POST /xms/v1/{plan}/batches, Bearer). Voice via Calling TTS callout. +public sealed class SinchProvider : IProviderAdapter +{ + public ProviderManifest Manifest { get; } = new( + Id: "sinch", + Auth: new AuthConfig("apiKey", KeyVaultSecretName: "sinch-api-token"), + ResponseMapping: new Dictionary + { + ["Dispatched"] = Outcome.Continue, + ["Delivered"] = Outcome.Continue, + ["Queued"] = Outcome.Continue, + ["Failed"] = Outcome.Fail, + ["Rejected"] = Outcome.Fail, + ["default"] = Outcome.Fail, + }); + + public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) + { + var bearer = credential.Mode == "oauth2" ? credential.Token : credential.Secret; + var headers = new Dictionary + { + ["Authorization"] = $"Bearer {bearer}", + ["Content-Type"] = "application/json", + ["Accept"] = "application/json", + }; + var reference = dispatch.CorrelationId ?? dispatch.MessageId; + + if (channel == "voice") + { + var voiceBase = env.Get("SINCH_VOICE_ENDPOINT") ?? "https://calling.api.sinch.com"; + var voiceBody = new + { + method = "ttsCallout", + ttsCallout = new + { + destination = new { type = "number", endpoint = dispatch.Destination }, + text = dispatch.Message, + locale = dispatch.Locale ?? "en-US", + custom = reference, + }, + }; + return new ProviderHttpRequest($"{voiceBase}/calling/v1/callouts", "POST", headers, JsonSerializer.Serialize(voiceBody)); + } + + var servicePlanId = env.Get("SINCH_SERVICE_PLAN_ID") ?? string.Empty; + var body = new + { + from = env.Get("SINCH_SENDER_ID") ?? "Verify", + to = new[] { dispatch.Destination }, + body = dispatch.Message, + client_reference = reference, + }; + return new ProviderHttpRequest($"{endpoint}/xms/v1/{servicePlanId}/batches", "POST", headers, JsonSerializer.Serialize(body)); + } + + public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) + { + string? id = null, desc = null; + if (json.ValueKind == JsonValueKind.Object) + { + if (json.TryGetProperty("id", out var idElement)) id = idElement.ToString(); + else if (json.TryGetProperty("callId", out var callIdElement)) id = callIdElement.ToString(); + if (json.TryGetProperty("text", out var textElement)) desc = textElement.GetString(); + } + return new ParsedResponse(ok, httpStatus, id, ok ? "Dispatched" : null, null, desc); + } +} diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs new file mode 100644 index 0000000..e4b8fd6 --- /dev/null +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -0,0 +1,98 @@ +using System.Text.Json; + +namespace Cyot.Otp.Providers; + +// Soprano Connect (MEMS): POST {base}/messages/{sms|voice}. Auth: X-MEMS-API-ID + X-MEMS-API-Key. +public sealed class SopranoProvider : IProviderAdapter +{ + public ProviderManifest Manifest { get; } = new( + Id: "soprano", + Auth: new AuthConfig("apiKey", KeyVaultSecretName: "soprano-api-key", IdentityKeyVaultSecretName: "soprano-api-id"), + ResponseMapping: new Dictionary + { + ["ENROUTE"] = Outcome.Continue, + ["ACCEPTED"] = Outcome.Continue, + ["SUBMITTED"] = Outcome.Continue, + ["SENT"] = Outcome.Continue, + ["DELIVERED"] = Outcome.Continue, + ["QUEUED"] = Outcome.Continue, + ["FAILED"] = Outcome.Fail, + ["REJECTED"] = Outcome.Fail, + ["BLOCKED"] = Outcome.Block, + ["default"] = Outcome.Fail, + }); + + public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) + { + var messageType = channel == "voice" ? "voice" : "sms"; + var headers = new Dictionary { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }; + if (credential.Mode == "oauth2") headers["Authorization"] = $"Bearer {credential.Token}"; + else { headers["X-MEMS-API-ID"] = credential.Identity ?? string.Empty; headers["X-MEMS-API-Key"] = credential.Secret ?? string.Empty; } + + object endpoints_or_source() + { + var sourceId = env.Get("SOPRANO_SOURCE_ID"); + if (!string.IsNullOrEmpty(sourceId)) + return new { endpoints = new[] { new { type = int.TryParse(env.Get("SOPRANO_SOURCE_TYPE"), out var parsedSourceType) ? parsedSourceType : 1, id = int.Parse(sourceId) } } }; + return new { source = env.Get("SOPRANO_SENDER_ID") }; + } + + var clientRef = dispatch.CorrelationId ?? dispatch.MessageId; + object body; + if (messageType == "voice") + { + var voiceLanguage = env.Get("SOPRANO_VOICE_LANGUAGE") ?? ((dispatch.Locale?.Contains('-') ?? false) ? dispatch.Locale! : "en-US"); + body = Merge(endpoints_or_source(), new + { + messageType, + destination = dispatch.Destination, + clientReference = clientRef, + voice = new + { + text2voice = new + { + beforePasswordText = dispatch.Message ?? string.Empty, + password = string.Empty, + afterPasswordText = string.Empty, + language = voiceLanguage, + gender = int.TryParse(env.Get("SOPRANO_VOICE_GENDER"), out var parsedGender) ? parsedGender : 1, + loop = 1, + }, + }, + }); + } + else + { + body = Merge(endpoints_or_source(), new { messageType, destination = dispatch.Destination, text = dispatch.Message, clientReference = clientRef }); + } + + return new ProviderHttpRequest($"{endpoint}/messages/{messageType}", "POST", headers, JsonSerializer.Serialize(body)); + } + + public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) + { + var payload = json.ValueKind == JsonValueKind.Array && json.GetArrayLength() > 0 ? json[0] : json; + string? id = null, status = null, desc = null; + if (payload.ValueKind == JsonValueKind.Object) + { + if (payload.TryGetProperty("id", out var idElement)) id = idElement.ToString(); + else if (payload.TryGetProperty("messageId", out var messageIdElement)) id = messageIdElement.ToString(); + if (payload.TryGetProperty("status", out var statusElement)) status = statusElement.GetString()?.ToUpperInvariant(); + else if (payload.TryGetProperty("state", out var stateElement)) status = stateElement.GetString()?.ToUpperInvariant(); + if (payload.TryGetProperty("errorDescription", out var errorElement)) desc = errorElement.GetString(); + else if (payload.TryGetProperty("statusText", out var statusTextElement)) desc = statusTextElement.GetString(); + else if (payload.TryGetProperty("description", out var descriptionElement)) desc = descriptionElement.GetString(); + } + status ??= ok ? "SUBMITTED" : null; + return new ParsedResponse(ok, httpStatus, id, status, null, desc); + } + + // Shallow-merge two anonymous objects into a dictionary for JSON serialization. + private static Dictionary Merge(object first, object second) + { + var merged = new Dictionary(); + foreach (var property in first.GetType().GetProperties()) merged[property.Name] = property.GetValue(first); + foreach (var property in second.GetType().GetProperties()) merged[property.Name] = property.GetValue(second); + return merged; + } +} diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs new file mode 100644 index 0000000..8f66098 --- /dev/null +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -0,0 +1,79 @@ +using System.Text; +using System.Text.Json; + +namespace Cyot.Otp.Providers; + +// Telesign: SMS via /v1/messaging, voice via /v1/voice (form-urlencoded). Auth: HTTP Basic (customer_id:api_key). +public sealed class TelesignProvider : IProviderAdapter +{ + public ProviderManifest Manifest { get; } = new( + Id: "telesign", + Auth: new AuthConfig("apiKey", KeyVaultSecretName: "telesign-api-key", IdentityKeyVaultSecretName: "telesign-customer-id"), + ResponseMapping: new Dictionary + { + ["200"] = Outcome.Continue, + ["203"] = Outcome.Continue, + ["290"] = Outcome.Continue, + ["291"] = Outcome.Continue, + ["292"] = Outcome.Continue, + ["100"] = Outcome.Continue, + ["101"] = Outcome.Continue, + ["102"] = Outcome.Continue, + ["103"] = Outcome.Continue, + ["default"] = Outcome.Fail, + }); + + public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) + { + var authorization = credential.Mode == "oauth2" + ? $"Bearer {credential.Token}" + : "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.Identity}:{credential.Secret}")); + + var externalId = dispatch.CorrelationId ?? dispatch.MessageId; + var form = new Dictionary(); + string path; + if (channel == "voice") + { + path = "/v1/voice"; + form["phone_number"] = dispatch.Destination; + form["message"] = dispatch.Message ?? string.Empty; + form["message_type"] = "OTP"; + form["voice"] = env.Get("TELESIGN_VOICE") ?? "f-en-US"; + form["external_id"] = externalId; + } + else + { + path = "/v1/messaging"; + form["phone_number"] = dispatch.Destination; + form["message"] = dispatch.Message ?? string.Empty; + form["sender_id"] = env.Get("TELESIGN_SENDER_ID") ?? string.Empty; + form["message_type"] = "OTP"; + form["external_id"] = externalId; + form["is_primary"] = "true"; + } + + var headers = new Dictionary + { + ["Authorization"] = authorization, + ["Content-Type"] = "application/x-www-form-urlencoded", + ["Accept"] = "application/json", + }; + var encoded = string.Join("&", form.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); + return new ProviderHttpRequest($"{endpoint}{path}", "POST", headers, encoded); + } + + public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) + { + string? refId = null, statusCode = null, statusDesc = null; + if (json.ValueKind == JsonValueKind.Object) + { + if (json.TryGetProperty("reference_id", out var referenceId)) refId = referenceId.GetString(); + if (json.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Object) + { + if (status.TryGetProperty("code", out var code) && code.ValueKind == JsonValueKind.Number) statusCode = code.GetInt32().ToString(); + if (status.TryGetProperty("description", out var description)) statusDesc = description.GetString(); + } + } + return new ParsedResponse(ok, httpStatus, refId, null, statusCode, statusDesc); + } +} diff --git a/dotnet/Src/SecretResolver.cs b/dotnet/Src/SecretResolver.cs new file mode 100644 index 0000000..b1824e3 --- /dev/null +++ b/dotnet/Src/SecretResolver.cs @@ -0,0 +1,39 @@ +using System.Collections.Concurrent; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; + +namespace Cyot.Otp; + +// Resolves Key Vault secret names to values via the Function's managed identity (user-assigned when +// AZURE_CLIENT_ID is set, else system-assigned), cached briefly so rotations are picked up. +public sealed class SecretResolver : ISecretResolver +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); + private readonly ConcurrentDictionary _cache = new(); + private readonly Lazy _client; + + public SecretResolver() + { + _client = new Lazy(() => + { + var url = Environment.GetEnvironmentVariable("KEY_VAULT_URL"); + if (string.IsNullOrWhiteSpace(url)) return null; + var clientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"); + var credential = string.IsNullOrEmpty(clientId) + ? new ManagedIdentityCredential() + : new ManagedIdentityCredential(clientId); + return new SecretClient(new Uri(url), credential); + }); + } + + public async Task ResolveAsync(string? secretName) + { + if (string.IsNullOrWhiteSpace(secretName)) return string.Empty; + if (_cache.TryGetValue(secretName, out var cached) && cached.Expires > DateTimeOffset.UtcNow) return cached.Value; + + var client = _client.Value ?? throw new InvalidOperationException("KEY_VAULT_URL not set"); + var value = (await client.GetSecretAsync(secretName)).Value.Value ?? string.Empty; + _cache[secretName] = (value, DateTimeOffset.UtcNow.Add(CacheTtl)); + return value; + } +} diff --git a/dotnet/Src/TokenValidator.cs b/dotnet/Src/TokenValidator.cs new file mode 100644 index 0000000..70bc8b7 --- /dev/null +++ b/dotnet/Src/TokenValidator.cs @@ -0,0 +1,57 @@ +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; + +namespace Cyot.Otp; + +// Validates the Entra JWT when REQUIRE_AUTH=true (aud=EXPECTED_AUDIENCE, issuer tenant=ISSUER_TENANT_ID, +// RS256 via JWKS). No-op pass-through when REQUIRE_AUTH is not "true". +public sealed class TokenValidator +{ + private readonly JwtSecurityTokenHandler _handler = new(); + private ConfigurationManager? _configManager; + + public sealed record Result(bool Ok, string? Reason = null, string? CallerObjectId = null); + + public async Task ValidateAsync(string? authorizationHeader) + { + if (!string.Equals(Environment.GetEnvironmentVariable("REQUIRE_AUTH"), "true", StringComparison.OrdinalIgnoreCase)) + return new Result(true); + + var audience = Environment.GetEnvironmentVariable("EXPECTED_AUDIENCE"); + var tenantId = Environment.GetEnvironmentVariable("ISSUER_TENANT_ID"); + if (string.IsNullOrEmpty(audience) || string.IsNullOrEmpty(tenantId)) + return new Result(false, "auth misconfigured"); + + if (string.IsNullOrEmpty(authorizationHeader) || !authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return new Result(false, "missing bearer token"); + + var token = authorizationHeader["Bearer ".Length..].Trim(); + var authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; + _configManager ??= new ConfigurationManager( + $"{authority}/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever()); + + try + { + var config = await _configManager.GetConfigurationAsync(); + var parameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuers = new[] { $"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/" }, + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + IssuerSigningKeys = config.SigningKeys, + ValidateIssuerSigningKey = true, + }; + var principal = _handler.ValidateToken(token, parameters, out _); + var oid = principal.FindFirst("oid")?.Value ?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value; + return new Result(true, CallerObjectId: oid); + } + catch + { + return new Result(false, "token validation failed"); + } + } +} diff --git a/dotnet/dotnet.csproj b/dotnet/dotnet.csproj new file mode 100644 index 0000000..dedc248 --- /dev/null +++ b/dotnet/dotnet.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/host.json b/dotnet/host.json new file mode 100644 index 0000000..bb804e0 --- /dev/null +++ b/dotnet/host.json @@ -0,0 +1,4 @@ +{ + "version": "2.0", + "telemetryMode": "OpenTelemetry" +} diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs new file mode 100644 index 0000000..69d3e93 --- /dev/null +++ b/dotnet/tests/ContractTests.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using Cyot.Otp; +using Cyot.Otp.Providers; +using Xunit; + +namespace Cyot.Otp.Tests; + +// Conformance tests for the pure contract logic (see /docs/CONTRACT.md §6). +public class ContractTests +{ + private sealed class FakeEnv : Dictionary, IEnv + { + public string? Get(string key) => TryGetValue(key, out var v) ? v : null; + } + + private static DispatchRequest Disp(string channel = "sms", string? message = null) => + new("+15551234567", message, channel, "m", "c", null); + + [Fact] + public void OutcomeMappingAndHttpStatus() + { + var m = new InfobipProvider().Manifest; + Assert.Equal(Outcome.Continue, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusName: "DELIVERED"))); + // Unknown status fails closed even on HTTP 200. + Assert.Equal(Outcome.Fail, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusName: "WATWAT"))); + Assert.Equal(200, OutcomeMapper.ToHttpStatus(Outcome.Continue, 200)); + Assert.Equal(403, OutcomeMapper.ToHttpStatus(Outcome.Block, 200)); + Assert.Equal(409, OutcomeMapper.ToHttpStatus(Outcome.StepUp, 200)); + Assert.Equal(429, OutcomeMapper.ToHttpStatus(Outcome.Fail, 429)); + Assert.Equal(401, OutcomeMapper.ToHttpStatus(Outcome.Fail, 403)); + Assert.Equal(400, OutcomeMapper.ToHttpStatus(Outcome.Fail, 422)); + Assert.Equal(502, OutcomeMapper.ToHttpStatus(Outcome.Fail, 500)); + } + + [Fact] + public void InfobipBuildsHttpsSmsRequestWithAppAuthAndCode() + { + var env = new FakeEnv { ["INFOBIP_SENDER_ID"] = "CYOT" }; + var req = new InfobipProvider().BuildRequest("sms", "https://api.infobip.com", + Disp(message: "Use verification code 918273 for Microsoft authentication."), + new ProviderCredential("apiKey", Secret: "ib"), env); + + Assert.StartsWith("https://", req.Url); + Assert.EndsWith("/sms/3/messages", req.Url); + Assert.StartsWith("App ", req.Headers["Authorization"]); + Assert.Contains("918273", req.Body); + } + + [Fact] + public void TelesignUsesBasicAuthAndVoiceMapping() + { + var env = new FakeEnv(); + var req = new TelesignProvider().BuildRequest("sms", "https://rest-api.telesign.com", + Disp(message: "code 918273"), new ProviderCredential("apiKey", Secret: "key", Identity: "cust"), env); + Assert.StartsWith("Basic ", req.Headers["Authorization"]); + Assert.EndsWith("/v1/messaging", req.Url); + + var m = new TelesignProvider().Manifest; + Assert.Equal(Outcome.Continue, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusCode: "100"))); + } + + [Fact] + public void ProviderRegistryResolvesById() + { + var reg = new ProviderRegistry(new IProviderAdapter[] + { + new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider(), + }); + Assert.Equal("telesign", reg.Get("TELESIGN")!.Manifest.Id); + Assert.Null(reg.Get("nope")); + } +} diff --git a/dotnet/tests/Cyot.Otp.Tests.csproj b/dotnet/tests/Cyot.Otp.Tests.csproj new file mode 100644 index 0000000..d103f94 --- /dev/null +++ b/dotnet/tests/Cyot.Otp.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs new file mode 100644 index 0000000..9e087f8 --- /dev/null +++ b/dotnet/tests/EngineTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Text; +using Cyot.Otp; +using Cyot.Otp.Providers; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Cyot.Otp.Tests; + +// Engine-level conformance tests (CONTRACT.md §6) with a fake Key Vault, HTTP client, and env. +public class EngineTests +{ + private sealed class FakeEnv : Dictionary, IEnv + { + public string? Get(string key) => TryGetValue(key, out var value) ? value : null; + } + + private sealed class FakeSecretResolver : ISecretResolver + { + private readonly IReadOnlyDictionary _values; + public FakeSecretResolver(IReadOnlyDictionary values) => _values = values; + public Task ResolveAsync(string? secretName) => + Task.FromResult(secretName != null && _values.TryGetValue(secretName, out var value) ? value : string.Empty); + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _responder; + public string? LastBody; + public StubHandler(Func responder) => _responder = responder; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Content != null) LastBody = await request.Content.ReadAsStringAsync(cancellationToken); + return _responder(request); + } + } + + private sealed class FakeHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + public FakeHttpClientFactory(HttpMessageHandler handler) => _handler = handler; + public HttpClient CreateClient(string name) => new(_handler); + } + + private sealed class CapturingLogger : ILogger + { + public readonly List Lines = new(); + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => Lines.Add(formatter(state, exception)); + private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } + } + + private static readonly Dictionary DefaultSecrets = new() + { + ["infobip-api-key"] = "ib", + ["telesign-api-key"] = "ts", ["telesign-customer-id"] = "cust", + }; + + private static FakeEnv DefaultEnv() => new() + { + ["INFOBIP_ENDPOINT"] = "https://api.infobip.com", + ["TELESIGN_ENDPOINT"] = "https://rest-api.telesign.com", + }; + + private static DispatchEngine Engine(HttpResponseMessage? response = null, Exception? throwOnSend = null, + IReadOnlyDictionary? secrets = null, FakeEnv? env = null, StubHandler? handler = null) + { + var registry = new ProviderRegistry(new IProviderAdapter[] { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() }); + var stub = handler ?? new StubHandler(_ => throwOnSend != null ? throw throwOnSend : response!); + return new DispatchEngine(registry, new FakeSecretResolver(secrets ?? DefaultSecrets), new FakeHttpClientFactory(stub), env ?? DefaultEnv()); + } + + private static DispatchRequest Disp(string channel = "sms", string? message = "Your code is 918273") => + new("+15551234567", message, channel, "m", "c", null); + + private static HttpResponseMessage Json(HttpStatusCode status, string body) => + new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + [Fact] + public async Task UnknownProvider_400() + { + var result = await Engine(Json(HttpStatusCode.OK, "{}")).DispatchAsync(Disp(), "nope", false, "r", new CapturingLogger()); + Assert.Equal(400, result.HttpStatus); + } + + [Fact] + public async Task MissingCredential_502() + { + var result = await Engine(Json(HttpStatusCode.OK, "{}"), secrets: new Dictionary()).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); + Assert.Equal(502, result.HttpStatus); + } + + [Fact] + public async Task MissingEndpoint_502() + { + var result = await Engine(Json(HttpStatusCode.OK, "{}"), env: new FakeEnv()).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); + Assert.Equal(502, result.HttpStatus); + } + + [Fact] + public async Task Shutter_DoesNotSend_200() + { + var handler = new StubHandler(_ => throw new Exception("should not send")); + var result = await Engine(handler: handler).DispatchAsync(Disp(), "infobip", true, "r", new CapturingLogger()); + Assert.Equal(200, result.HttpStatus); + Assert.Null(handler.LastBody); + } + + [Fact] + public async Task Success_RendersCode_AndKeepsPrivacy() + { + var handler = new StubHandler(_ => Json(HttpStatusCode.OK, "{\"messages\":[{\"status\":{\"name\":\"DELIVERED\"},\"messageId\":\"x\"}]}")); + var logger = new CapturingLogger(); + var result = await Engine(handler: handler).DispatchAsync(Disp(), "infobip", false, "r", logger); + + Assert.Equal(200, result.HttpStatus); + Assert.Contains("918273", handler.LastBody); // message (with the code) IS sent to the provider + var bodyJson = System.Text.Json.JsonSerializer.Serialize(result.Body); + Assert.DoesNotContain("918273", bodyJson); // never in the response body + Assert.DoesNotContain("5551234567", bodyJson); + Assert.All(logger.Lines, line => Assert.DoesNotContain("918273", line)); // never logged + Assert.All(logger.Lines, line => Assert.DoesNotContain("5551234567", line)); + } + + [Fact] + public async Task UnknownStatus_FailsClosed() + { + var result = await Engine(Json(HttpStatusCode.OK, "{\"messages\":[{\"status\":{\"name\":\"WATWAT\"}}]}")).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); + Assert.Equal(502, result.HttpStatus); // Fail on HTTP 200 -> 502 + } + + [Fact] + public async Task Timeout_504() + { + var result = await Engine(throwOnSend: new TaskCanceledException()).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); + Assert.Equal(504, result.HttpStatus); + } + + [Fact] + public async Task NetworkError_502() + { + var result = await Engine(throwOnSend: new HttpRequestException("dns")).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); + Assert.Equal(502, result.HttpStatus); + } +} diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs new file mode 100644 index 0000000..139f11c --- /dev/null +++ b/dotnet/tests/EnvelopeTests.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Cyot.Otp; +using Xunit; + +namespace Cyot.Otp.Tests; + +// Envelope validation + JWE decryption round-trip (see docs/CONTRACT.md §1, §6). +public class EnvelopeTests +{ + private static JsonElement Payload(string json) => JsonDocument.Parse(json).RootElement; + + private sealed class FakeKeyProvider : IJweKeyProvider + { + private readonly RSA _rsa; + public FakeKeyProvider(RSA rsa) => _rsa = rsa; + public RSA GetPrivateKey(string? kid) => _rsa; + } + + [Fact] + public void MissingEncryptedContext_IsError() + { + var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":1,\"mode\":1}")); + Assert.Null(envelope); + Assert.Contains("encryptedDeliveryContext", error); + } + + [Fact] + public void UnsupportedChannel_IsError() + { + var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":9,\"mode\":1,\"encryptedDeliveryContext\":\"x\"}")); + Assert.Null(envelope); + Assert.Contains("channel", error); + } + + [Fact] + public void UnsupportedMode_IsError() + { + var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":1,\"mode\":5,\"encryptedDeliveryContext\":\"x\"}")); + Assert.Null(envelope); + Assert.Contains("mode", error); + } + + [Fact] + public void ValidEnvelope_Parses() + { + var (envelope, error) = EnvelopeParser.Parse(Payload( + "{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"tenantId\":\"t\",\"correlationId\":\"c\",\"channel\":2,\"mode\":1,\"ttlSeconds\":60,\"encryptedDeliveryContext\":\"x\"}")); + Assert.Null(error); + Assert.NotNull(envelope); + Assert.Equal(2, envelope!.Channel); + Assert.Equal(1, envelope.Mode); + Assert.Equal("voice", EnvelopeParser.ChannelName(envelope.Channel)); + } + + [Fact] + public void Jwe_RoundTrips_ToDeliveryContext() + { + using var rsa = RSA.Create(2048); + var contextJson = JsonSerializer.Serialize(new + { + nonce = "nonce-1", + phoneNumber = "+14255551234", + message = "Your code is 123456", + locale = "en-US", + }); + var jwe = Jose.JWT.Encode(contextJson, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM, + extraHeaders: new Dictionary { ["kid"] = "test-key" }); + + Assert.Equal("test-key", JweDecryptor.ReadKid(jwe)); + + var context = new JweDecryptor(new FakeKeyProvider(rsa)).Decrypt(jwe); + Assert.Equal("nonce-1", context.Nonce); + Assert.Equal("+14255551234", context.PhoneNumber); + Assert.Equal("Your code is 123456", context.Message); + Assert.Equal("en-US", context.Locale); + } +} diff --git a/javascript/README.md b/javascript/README.md new file mode 100644 index 0000000..6304ea1 --- /dev/null +++ b/javascript/README.md @@ -0,0 +1,196 @@ +# CYOT — Azure Function (delivery endpoint) + +An Azure Function (Node.js) that receives an OTP dispatch request and forwards it to a telephony +provider (**Infobip**, **Telesign**, **Sinch**, or **Soprano**). + +## What it does + +- `POST /api/SendOtp` — dispatches the OTP to the selected provider and returns an accepted/failed result. +- Provider secrets from **Azure Key Vault** (managed identity). +- **Correlation id** propagated to the provider and echoed back. +- **Shutter mode** — process the full path but do not send. +- **Optional token validation** — off by default; enable with `REQUIRE_AUTH=true`. + +> Token validation exists but is **off by default** — enable it in any real deployment (`REQUIRE_AUTH=true`). + +## Deploy + +1. **Create the Key Vault** and add what the Function reads: + - the provider **API key/token** as a **secret** (default name `infobip-api-key` — see [Configuration](#configuration)). +2. **Grant the Function's managed identity** on that vault: **Key Vault Secrets User**. +3. **Set the app settings** — copy [`../docs/local.settings.sample.json`](../docs/local.settings.sample.json) into `src/local.settings.json` locally; in Azure set them under **Function App → Settings → Environment variables**. At minimum set `KEY_VAULT_URL` and `DEFAULT_PROVIDER`; see [Configuration](#configuration) for the full list. +4. **Publish:** + +```bash +cd src +npm install +func azure functionapp publish +``` + +## Configuration + +CYOT is **plug-and-play by provider**. The **shared infrastructure** — token validation, dispatch, +response normalization, message templating, and +logging — is identical for every provider and needs no per-provider code. You **choose one provider**; +the only provider-specific parts are its **adapter** (the outbound API call) and the **few settings** below. + +> A new provider is onboarded by dropping in a single file `providers/.js` that exports its +> `manifest` (built-in defaults: endpoints, channels, auth, responseMapping) plus `buildRequest` / +> `parseResponse` — no change to the shared pipeline. + +> **Provisioning model.** The provider's authoritative parameters live in its **Security Store package +> manifest**. At provisioning time, UX reads that manifest and sets the operational values as **app +> settings (env properties)** on the Function — endpoint URLs (`_ENDPOINT`, `EUDB`), sender/source +> IDs, `ENDPOINT_TIMEOUT_MS`, and the Key Vault secret references. The values baked +> into `providers/.js` are just **local-dev defaults**; the app settings win. Only the **adapter +> code** (`buildRequest`/`parseResponse`) is provider-specific code — everything else is data. + +Provider secrets are read from **Key Vault** by name via the Function's managed identity. Set +`KEY_VAULT_URL` to the vault URI. Each provider's secret name is fixed in its manifest +(`infobip-api-key` / `telesign-api-key` / `sinch-api-token` / `soprano-api-key`); the value lives in +Key Vault and can be rotated there without a redeploy. + +### Shared settings (always) + +| Key | Purpose | +|-----|---------| +| `DEFAULT_PROVIDER` | your chosen provider: `infobip` \| `telesign` \| `sinch` \| `soprano` | +| `KEY_VAULT_URL` | Key Vault URI (required) | +| `EUDB` | `true` for an EU Data Boundary deployment — uses each provider's EU endpoint URL (optional) | +| `ENDPOINT_TIMEOUT_MS` | outbound provider-call timeout in ms (default `1500`) | +| `REQUIRE_AUTH` | `true` to enforce token validation — enable in any real deployment | +| `EXPECTED_AUDIENCE` | token `aud` (this endpoint's app registration appId) — required when `REQUIRE_AUTH=true` | +| `ISSUER_TENANT_ID` | customer tenant id for issuer/JWKS — required when `REQUIRE_AUTH=true` | + +### Per-provider settings (set only for the provider you chose) + +Set `DEFAULT_PROVIDER` to your provider, then provision **only that block** — its **Key Vault secret** +(the API key/token — the *only* secret) plus its **non-secret app settings**: the endpoint +(`_ENDPOINT` / `_EUDB`), sender/source id, etc. Endpoints, sender ids, `KEY_VAULT_URL`, and the +Key Vault secret **names** are all non-secret configuration; only the key/token **value** lives in Key Vault. + +**Infobip** +| Setting | Purpose | +|---------|---------| +| Key Vault secret `infobip-api-key` | API key | +| `INFOBIP_SENDER_ID` | registered sender, app setting (default `Verify`) | +| `INFOBIP_VOICE_FROM` | voice caller id, app setting (optional; falls back to `INFOBIP_SENDER_ID`) | + +**Telesign** +| Setting | Purpose | +|---------|---------| +| Key Vault secret `telesign-api-key` | API key | +| Key Vault secret `telesign-customer-id` | customer id (the Basic-auth username) | +| `TELESIGN_SENDER_ID` | sender id, app setting (optional) | +| `TELESIGN_VOICE` | voice language/voice code for voice OTP, app setting (optional; default `f-en-US`) | + +**Sinch** +| Setting | Purpose | +|---------|---------| +| Key Vault secret `sinch-api-token` | API token | +| `SINCH_SERVICE_PLAN_ID` | XMS service plan id, app setting | +| `SINCH_SENDER_ID` | sender, app setting (default `Verify`) | +| `SINCH_VOICE_ENDPOINT` | Sinch Voice API host, app setting (optional; default `https://calling.api.sinch.com`) | + +**Soprano** +| Setting | Purpose | +|---------|---------| +| Key Vault secret `soprano-api-key` | API key (sent as the `X-MEMS-API-Key` header) | +| Key Vault secret `soprano-api-id` | API ID (sent as the `X-MEMS-API-ID` header) | +| `SOPRANO_ENDPOINT` | **required** — your MEMS API base `https:///cgpapi` (per-customer; no default) | +| `SOPRANO_SOURCE_ID` | provisioned source/sender endpoint id, app setting — Soprano requires a provisioned sender, sent as `endpoints:[{type,id}]` | +| `SOPRANO_SOURCE_TYPE` | provisioned source endpoint type, app setting (optional; default `1`) | +| `SOPRANO_SENDER_ID` | optional free-text sender, used only as a fallback when `SOPRANO_SOURCE_ID` is unset | + +> Optional per-provider `_ENDPOINT` overrides the manifest URL (e.g. a sandbox host); rarely needed. + +### Identity & permissions (managed identity — no static credentials) + +The Function authenticates to Key Vault (and any other Azure resource) with its **managed identity** (user-assigned when `AZURE_CLIENT_ID` is set, else system-assigned) — there are **no secrets, keys, or connection strings in code or config**. Grant it **least-privilege** access on the customer's vault: + +| Scope | Role | Why | +|-------|------|-----| +| The provider **secret** (or the vault) | **Key Vault Secrets User** | `get` the provider API key/token | + +Also use an **identity-based** `AzureWebJobsStorage` connection (managed identity) instead of a storage connection string, so the runtime holds no static secret either. All resource **names** (`KEY_VAULT_URL`, `EXPECTED_AUDIENCE`, `ISSUER_TENANT_ID`) come from app settings — nothing is hard-coded. + +### Add your own provider + +Onboarding a provider is **one file** — `src/functions/providers/.js` — with no change to the shared pipeline. Copy an existing provider (e.g. [infobip.js](src/functions/providers/infobip.js)) and export three things: + +```js +// 1) manifest — the provider's protocol facts the engine reads +const manifest = { + id: 'acme', // provider id (used as the `Provider` value); the URL + // app setting is `_ENDPOINT`, e.g. ACME_ENDPOINT + auth: { mode: 'apiKey', keyVaultSecretName: 'acme-api-key' }, + responseMapping: { SENT: 'Continue', FAILED: 'Fail', default: 'Fail' }, // provider status → outcome +}; + +// 2) buildRequest — shape the outbound HTTP call +function buildRequest({ channel, endpoint, dispatch, credential, env }) { + return { + url: `${endpoint}/messages`, + method: 'POST', + headers: { Authorization: `Bearer ${credential.secret}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ to: dispatch.destination, text: dispatch.message }), + }; +} + +// 3) parseResponse — normalize the provider reply +function parseResponse({ httpStatus, ok, json }) { + return { + success: ok, + providerHttpStatus: httpStatus, + providerMessageId: (json && json.id) || null, + providerStatusName: (json && json.status) || (ok ? 'SENT' : null), + providerStatusDescription: (json && json.description) || null, + }; +} + +module.exports = { manifest, buildRequest, parseResponse }; +``` + +The engine handles the rest — provider resolution, Key Vault credential fetch (via managed identity), message templating, timeout, `responseMapping` → HTTP status, and fail-closed behavior. Drop the file in, add the Key Vault secret, set `DEFAULT_PROVIDER=acme`, and it works. + +## Request contract + +`POST /api/SendOtp` — the SAS → CYOT delivery endpoint. The cleartext body is a routing envelope; the +PII (phone + rendered message, which contains the passcode) is encrypted in a JWE. See +[../docs/CONTRACT.md](../docs/CONTRACT.md) for the full contract. + +| Field | Required | Notes | +|-------|----------|-------| +| `type` | yes | envelope version, e.g. `microsoft.mfa.otpDeliver.v1` | +| `channel` | yes | `1`=Sms, `2`=Voice | +| `mode` | yes | `1`=Live, `2`=Evaluation (rehearsal — not delivered) | +| `encryptedDeliveryContext` | yes | JWE (RSA-OAEP-256 + A256GCM); decrypts to `{ nonce, phoneNumber, message, locale?, riskContext? }` | +| `tenantId`, `correlationId`, `ttlSeconds` | no | routing / tracing / passcode validity | + +The active provider is deployment config (`DEFAULT_PROVIDER`), not a request field. The response is the +`CyotEndpointResponse`: `{ "nonce": "", "correlationId": "", "providerStatus": "accepted" }`. +A `2xx` with a matching nonce means handled; non-2xx / nonce mismatch / timeout → SAS falls back to CAPP. + +## Try it + +The private RSA key that decrypts `encryptedDeliveryContext` is resolved from Key Vault by the JOSE `kid` +(or `CYOT_JWE_PRIVATE_KEY_PEM` for local dev). Build the envelope with the matching public key: + +```bash +curl -X POST https://.azurewebsites.net/api/SendOtp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "x-ms-correlation-id: test-001" \ + -d '{ + "type": "microsoft.mfa.otpDeliver.v1", + "tenantId": "", + "correlationId": "test-001", + "channel": 1, + "mode": 1, + "ttlSeconds": 60, + "encryptedDeliveryContext": "" + }' +# -> 202 { "nonce": "", "correlationId": "test-001", "providerStatus": "accepted" } +``` + +Evaluation mode (`"mode": 2`) runs everything except the actual send and still echoes the nonce. diff --git a/javascript/host.json b/javascript/host.json new file mode 100644 index 0000000..06d01bd --- /dev/null +++ b/javascript/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/javascript/package-lock.json b/javascript/package-lock.json new file mode 100644 index 0000000..0910ff8 --- /dev/null +++ b/javascript/package-lock.json @@ -0,0 +1,658 @@ +{ + "name": "cyot-poc", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cyot-poc", + "version": "1.0.0", + "dependencies": { + "@azure/functions": "^4.0.0", + "@azure/identity": "^4.13.1", + "@azure/keyvault-secrets": "^4.11.2", + "jose": "^5.9.6" + } + }, + "node_modules/@azure-rest/core-client": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.7.0.tgz", + "integrity": "sha512-rL0lJqh1E8HLXNgjIw8cRyGAV/v+m6p1xRu/8OhsnmN8XHhwkyYJkAoGM+zrew96v7jZYPmVfy7pv7v4Iccfsg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.24.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", + "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", + "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/functions": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.16.1.tgz", + "integrity": "sha512-A9obwC7IBg4NAmxUfTVfYEd8Xg6Px+o85JRprS3UJZt+GYYzIOmEecnFwTe3rl+aiHDewBk/8fnIVrSjR/fNGQ==", + "license": "MIT", + "dependencies": { + "@azure/functions-extensions-base": "0.3.0", + "cookie": "^0.7.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@azure/functions-extensions-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@azure/functions-extensions-base/-/functions-extensions-base-0.3.0.tgz", + "integrity": "sha512-Cux0hLu5ZXlC/Kb+yvJVhRLIdkfFwui2HeT5oGZL00r/GCUUkhGTzRfZUjRN4Bq729mPv3okPucz2z7SMQLStA==", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz", + "integrity": "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-secrets": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@azure/keyvault-secrets/-/keyvault-secrets-4.11.2.tgz", + "integrity": "sha512-ECj/kwZbZlQXj2kfWivSICbKwj6W3chmFhv8qUdauqYnjvZ0hWZBFSsZWux7W2nX3MP49PLUCusXk+hAg3pipg==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.1.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.15.0.tgz", + "integrity": "sha512-2NYT6v+eeQn8kmNddr9LnbXSvXbVELpmFMmfFvtRxD7I/5+5GlkMlncApeuRFj+mY6C9syOwQip1a0Y+TIbyiA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.10.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.10.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.10.0.tgz", + "integrity": "sha512-iYtjpanlv6963Jprs0MvzIap07V+QhultjQctfbEDQCflsDAEeO3R7XnVA5gk30fhoBFLdgJT7VqO0TGsEsN9w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.3.0.tgz", + "integrity": "sha512-fXtJX811pX8y8QlrQqBSH6+plvWyKZDI0IxkheAcyAw9OtcpXyFivmTC7eGUqutLWaDlKXuQ3yOESD4zAmkjHg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.10.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jose/-/jose-5.10.0.tgz", + "integrity": "sha1-w3NGoJnWRnxAE1GpoMIWHg9SxL4=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/javascript/package.json b/javascript/package.json new file mode 100644 index 0000000..7b0ab25 --- /dev/null +++ b/javascript/package.json @@ -0,0 +1,16 @@ +{ + "name": "cyot-poc", + "version": "1.0.0", + "description": "CYOT delivery endpoint - send OTP via Infobip, Telesign, Sinch, or Soprano", + "main": "src/functions/*.js", + "scripts": { + "start": "func start", + "test": "node --test" + }, + "dependencies": { + "@azure/functions": "^4.0.0", + "@azure/identity": "^4.13.1", + "@azure/keyvault-secrets": "^4.11.2", + "jose": "^5.9.6" + } +} diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js new file mode 100644 index 0000000..8aa8c11 --- /dev/null +++ b/javascript/src/functions/SendOtp.js @@ -0,0 +1,99 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +// POST /api/SendOtp — the SAS → CYOT delivery endpoint. Validates the Entra token, parses the cleartext +// routing envelope, decrypts the JWE delivery context (PII lives there), dispatches to the provider, and +// echoes the nonce to prove decryption. Privacy: the OTP code, phone digits, and decrypted context are +// never logged (presence only); the response body is the minimal CyotEndpointResponse. + +const { app } = require('@azure/functions'); +const crypto = require('crypto'); +const { validateToken } = require('./security'); +const { dispatchOtp } = require('./dispatch'); +const { parseEnvelope, decryptDeliveryContext, contextToDispatch, MODE } = require('./cyot'); + +app.http('SendOtp', { + methods: ['POST'], + authLevel: 'anonymous', + handler: async (request, context) => { + const callerOid = request.headers.get('x-ms-client-principal-id') || null; + const requestId = crypto.randomUUID(); + const clientRequestId = request.headers.get('x-ms-client-request-id') || requestId; + const headerCorrelationId = request.headers.get('x-ms-correlation-id') || null; + + const tokenValidation = await validateToken(request, context, requestId); + if (!tokenValidation.ok) { + context.log(`[AUTH_REJECT] requestId=${requestId} reason=${tokenValidation.reason}`); + return { status: 401, jsonBody: { error: 'unauthorized', reason: tokenValidation.reason, requestId } }; + } + + let payload; + let rawBody; + try { + rawBody = await request.text(); + payload = JSON.parse(rawBody); + } catch { + context.log(`[ERROR] requestId=${requestId} invalid JSON body bytes=${rawBody ? rawBody.length : 0}`); + return { status: 400, jsonBody: { error: 'bad_request', reason: 'invalid JSON body', requestId } }; + } + + const { envelope, error: envelopeError } = parseEnvelope(payload); + if (envelopeError) { + context.log(`[VALIDATION_ERROR] requestId=${requestId} ${envelopeError}`); + return { status: 400, jsonBody: { error: 'bad_request', reason: envelopeError, requestId } }; + } + const correlationId = envelope.correlationId || headerCorrelationId || requestId; + + let deliveryContext; + try { + deliveryContext = await decryptDeliveryContext(envelope.encryptedDeliveryContext, { env: process.env }); + } catch (decryptError) { + context.log(`[DECRYPT_ERROR] requestId=${requestId} correlationId=${correlationId} reason=${decryptError.message}`); + return { status: 400, jsonBody: { error: 'decryption_failed', correlationId, requestId } }; + } + + if (!deliveryContext || !deliveryContext.nonce || !deliveryContext.phoneNumber || !deliveryContext.message) { + context.log(`[VALIDATION_ERROR] requestId=${requestId} correlationId=${correlationId} incomplete delivery context`); + return { status: 400, jsonBody: { error: 'bad_request', reason: 'incomplete delivery context', correlationId, requestId } }; + } + + const nonce = deliveryContext.nonce; + const evaluation = envelope.mode === MODE.EVALUATION; + + // Respect ttlSeconds: don't start a live delivery for an already-expired passcode (contract §7). + if (!evaluation && typeof envelope.ttlSeconds === 'number' && envelope.ttlSeconds <= 0) { + context.log(`[EXPIRED] requestId=${requestId} correlationId=${correlationId} ttl=${envelope.ttlSeconds}`); + return { status: 400, jsonBody: { error: 'request_expired', correlationId, requestId } }; + } + + context.log( + `[SENDOTP] requestId=${requestId} caller=${callerOid || 'n/a'} type=${envelope.type || 'n/a'} ` + + `tenant=${envelope.tenantId || 'n/a'} correlationId=${correlationId} channel=${envelope.channel} mode=${envelope.mode} ` + + `ttl=${envelope.ttlSeconds ?? 'n/a'} phone=present message=present risk=${deliveryContext.riskContext ? 'present' : 'absent'}` + ); + + const dispatch = contextToDispatch(deliveryContext, envelope, clientRequestId); + + try { + const { httpStatus } = await dispatchOtp(dispatch, { + tenantId: envelope.tenantId, + requestProvider: undefined, + shutter: evaluation, + context, + requestId, + }); + // Contract: acceptance is 202 Accepted (async delivery); the engine signals acceptance as 200. + const accepted = httpStatus === 200; + return { + status: accepted ? 202 : httpStatus, + jsonBody: { nonce, correlationId, providerStatus: accepted ? 'accepted' : 'failed' }, + }; + } catch (error) { + context.log(`[EXCEPTION] requestId=${requestId} error=${error.message} stack=${error.stack}`); + return { status: 500, jsonBody: { nonce, correlationId, providerStatus: 'failed' } }; + } + }, +}); diff --git a/javascript/src/functions/cyot.js b/javascript/src/functions/cyot.js new file mode 100644 index 0000000..63c69e8 --- /dev/null +++ b/javascript/src/functions/cyot.js @@ -0,0 +1,130 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +// SAS → CYOT envelope handling. SAS sends a cleartext routing envelope (SendCyotOtpRequest) whose PII +// (phone + rendered message, which contains the passcode) is encrypted in a JWE. This module parses the +// envelope, decrypts the JWE delivery context (RSA-OAEP-256 + A256GCM, key selected by the JOSE `kid`), +// and maps the CyotDeliveryContext onto the dispatch engine's request shape. + +const crypto = require('crypto'); +const { compactDecrypt } = require('jose'); +const { resolveSecretValue } = require('./dispatch'); + +// CyotChannel: 1=Sms, 2=Voice (0=Undefined). CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). +const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); +const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); +const MODE = Object.freeze({ LIVE: 1, EVALUATION: 2 }); +const MODE_BY_NAME = Object.freeze({ live: 1, evaluation: 2 }); + +// channel/mode accept the int enum (1/2) or the string form ("sms"/"voice", "live"/"evaluation"). +function normalizeChannel(channel) { + if (CHANNEL_BY_CODE[channel]) return Number(channel); + if (typeof channel === 'string' && CHANNEL_BY_NAME[channel.toLowerCase()]) return CHANNEL_BY_NAME[channel.toLowerCase()]; + return null; +} +function normalizeMode(mode) { + if (mode === MODE.LIVE || mode === MODE.EVALUATION) return mode; + if (typeof mode === 'string' && MODE_BY_NAME[mode.toLowerCase()]) return MODE_BY_NAME[mode.toLowerCase()]; + return null; +} + +// Validates + normalizes the cleartext envelope. Returns { envelope } or { error }. +function parseEnvelope(payload) { + if (!payload || typeof payload !== 'object') { + return { error: 'invalid envelope' }; + } + const { type, tenantId, correlationId, channel, mode, ttlSeconds, encryptedDeliveryContext } = payload; + if (typeof encryptedDeliveryContext !== 'string' || !encryptedDeliveryContext) { + return { error: 'encryptedDeliveryContext is required' }; + } + const channelCode = normalizeChannel(channel); + if (!channelCode) { + return { error: `unsupported channel '${channel}'` }; + } + const modeCode = normalizeMode(mode); + if (!modeCode) { + return { error: `unsupported mode '${mode}'` }; + } + return { envelope: { type, tenantId, correlationId, channel: channelCode, mode: modeCode, ttlSeconds, encryptedDeliveryContext } }; +} + +// Reject oversized or structurally invalid JWEs before base64-decoding or allocating buffers. +const MAX_JWE_LENGTH = 16384; + +function assertWellFormedJwe(compactJwe) { + if (typeof compactJwe !== 'string' || compactJwe.length === 0) { + throw new Error('malformed JWE'); + } + if (compactJwe.length > MAX_JWE_LENGTH) { + throw new Error('delivery context exceeds size limit'); + } + const segments = compactJwe.split('.'); + if (segments.length !== 5 || segments.some((segment) => segment.length === 0)) { + throw new Error('malformed JWE: expected five non-empty segments'); + } +} + +// Reads the `kid` from the JWE protected (first) segment without decrypting. +function readKid(compactJwe) { + const protectedSegment = String(compactJwe).split('.')[0] || ''; + const header = JSON.parse(Buffer.from(protectedSegment, 'base64url').toString('utf8')); + return header.kid || null; +} + +// Default key source: an inline PEM (local/dev) or a Key Vault secret (deployed). The `kid` selects the +// secret name when JWE_PRIVATE_KEY_SECRET is not set. +async function resolvePrivateKeyPem(kid, env) { + if (env.CYOT_JWE_PRIVATE_KEY_PEM) { + return env.CYOT_JWE_PRIVATE_KEY_PEM; + } + const secretName = env.JWE_PRIVATE_KEY_SECRET || kid; + if (!secretName) { + return ''; + } + return resolveSecretValue(secretName); +} + +// Decrypts the JWE compact serialization to the CyotDeliveryContext. `keyProvider(kid)` is injectable +// so tests can supply a local key instead of Key Vault. +async function decryptDeliveryContext(compactJwe, options = {}) { + const env = options.env || process.env; + assertWellFormedJwe(compactJwe); + const keyProvider = options.keyProvider || ((kid) => resolvePrivateKeyPem(kid, env)); + const kid = readKid(compactJwe); + const pem = await keyProvider(kid); + if (!pem) { + throw new Error('private key unavailable'); + } + const privateKey = crypto.createPrivateKey(pem); + // Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). + const { plaintext } = await compactDecrypt(compactJwe, privateKey, { + keyManagementAlgorithms: ['RSA-OAEP-256'], + contentEncryptionAlgorithms: ['A256GCM'], + }); + return JSON.parse(Buffer.from(plaintext).toString('utf8')); +} + +// Maps the decrypted context + envelope onto the engine's dispatch shape. The message is pre-rendered +// (already contains the passcode), so there is no separate code — the fields mirror CyotDeliveryContext. +function contextToDispatch(context, envelope, messageId) { + return { + destination: context.phoneNumber, + message: context.message, + channel: CHANNEL_BY_CODE[envelope.channel], + messageId, + correlationId: envelope.correlationId, + locale: context.locale || undefined, + }; +} + +module.exports = { + parseEnvelope, + readKid, + decryptDeliveryContext, + contextToDispatch, + CHANNEL_BY_CODE, + MODE, +}; diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js new file mode 100644 index 0000000..0f85af1 --- /dev/null +++ b/javascript/src/functions/dispatch.js @@ -0,0 +1,387 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +// OTP delivery engine. Each provider is a manifest + adapter under ./providers/.js. dispatchOtp +// resolves the provider, sends via its adapter with a timeout, then maps the provider status to an +// outcome and an HTTP status. Fail-closed: only a Continue outcome is "accepted". + + +const fileSystem = require('fs'); +const path = require('path'); +const { ManagedIdentityCredential } = require('@azure/identity'); +const { SecretClient } = require('@azure/keyvault-secrets'); + +// ─── Shared constants ──────────────────────────────────────────────────────── + +const OUTCOME = Object.freeze({ + CONTINUE: 'Continue', + FAIL: 'Fail', + BLOCK: 'Block', + STEP_UP: 'StepUp', +}); + +const HTTP_STATUS = Object.freeze({ + OK: 200, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + CONFLICT: 409, + TOO_MANY_REQUESTS: 429, + BAD_GATEWAY: 502, + GATEWAY_TIMEOUT: 504, +}); + +const RESPONSE_STATUS = Object.freeze({ + ACCEPTED: 'accepted', + FAILED: 'failed', + ERROR: 'error', +}); + +const DEFAULTS = Object.freeze({ + CHANNEL: 'sms', + ENDPOINT_TIMEOUT_MILLISECONDS: 1500, + CHANNELS: ['sms', 'voice'], +}); + +const PROVIDERS_DIRECTORY = path.join(__dirname, 'providers'); +const SECRET_CACHE_TIME_TO_LIVE_MILLISECONDS = 5 * 60 * 1000; // rotated secrets picked up within this window + +// ─── Provider registry ─────────────────────────────────────────────────────── +// Each ./providers/.js exports { manifest, buildRequest, parseResponse }. Onboarding = drop in a file. + +let providerRegistry = null; + +// Builds and caches the registry once (keyed by lowercased id); a malformed file is skipped, not fatal. +function loadProviderRegistry() { + if (providerRegistry) { + return providerRegistry; + } + providerRegistry = new Map(); + + let providerFileNames = []; + try { + providerFileNames = fileSystem.readdirSync(PROVIDERS_DIRECTORY).filter((fileName) => fileName.endsWith('.js')); + } catch { + providerFileNames = []; + } + + for (const fileName of providerFileNames) { + try { + // eslint-disable-next-line global-require + const providerModule = require(path.join(PROVIDERS_DIRECTORY, fileName)); + const manifest = providerModule && providerModule.manifest; + if (!manifest || !manifest.id) { + continue; + } + providerRegistry.set(String(manifest.id).toLowerCase(), { manifest, adapter: providerModule }); + } catch (error) { + console.warn(`[providers] skipping '${fileName}': ${error.message}`); + } + } + + return providerRegistry; +} + +function getProvider(providerId) { + if (!providerId) { + return null; + } + return loadProviderRegistry().get(String(providerId).toLowerCase()) || null; +} + +// The request's Provider, else the deployment's DEFAULT_PROVIDER (set by UX at provisioning). One +// provider is active per deployment — selection is config, not routing the endpoint performs. +function resolveProvider(requestProvider) { + const providerId = (requestProvider || process.env.DEFAULT_PROVIDER || '').toLowerCase(); + return getProvider(providerId); +} + +// ─── Provider credentials (Key Vault via managed identity) ──────────────────────── +// The manifest carries only the secret's *name*; the value is read just-in-time and never logged. + +let keyVaultSecretClient = null; +const secretCache = new Map(); + +// Key Vault is accessed via the Function's managed identity (user-assigned when AZURE_CLIENT_ID is set, +// else system-assigned). The identity needs the Key Vault Secrets User role on the vault. +function createManagedIdentityCredential() { + return process.env.AZURE_CLIENT_ID + ? new ManagedIdentityCredential(process.env.AZURE_CLIENT_ID) + : new ManagedIdentityCredential(); +} + +function getKeyVaultSecretClient() { + if (!keyVaultSecretClient) { + keyVaultSecretClient = new SecretClient(process.env.KEY_VAULT_URL, createManagedIdentityCredential()); + } + return keyVaultSecretClient; +} + +// Resolves a Key Vault secret name to its value (cached briefly so rotations are picked up). +async function resolveSecretValue(keyVaultSecretName) { + if (!keyVaultSecretName) { + return ''; + } + const cachedSecret = secretCache.get(keyVaultSecretName); + if (cachedSecret && cachedSecret.expiresAt > Date.now()) { + return cachedSecret.value; + } + + const secretValue = (await getKeyVaultSecretClient().getSecret(keyVaultSecretName)).value || ''; + + secretCache.set(keyVaultSecretName, { + value: secretValue, + expiresAt: Date.now() + SECRET_CACHE_TIME_TO_LIVE_MILLISECONDS, + }); + return secretValue; +} + +// Best-effort warm-up at startup so the first request doesn't pay the cold Key Vault cost. Never throws. +async function warmUpSecretCache() { + if (!process.env.KEY_VAULT_URL) { + return; + } + const providerId = (process.env.DEFAULT_PROVIDER || '').toLowerCase(); + const providerEntry = providerId && getProvider(providerId); + const keyVaultSecretName = providerEntry && providerEntry.manifest.auth && providerEntry.manifest.auth.keyVaultSecretName; + if (!keyVaultSecretName) { + return; + } + try { + await resolveSecretValue(keyVaultSecretName); + } catch { + // ignore + } +} + +// Resolves the outbound credential from the manifest auth block: apiKey (secret [+identity]) or oauth2 (token). +async function resolveProviderCredential(authConfiguration = {}, options = {}) { + const authenticationMode = authConfiguration.mode || 'apiKey'; + + if (authenticationMode === 'oauth2') { + // oauth2 not wired end-to-end yet — no injected acquireProviderToken, so it fails closed. + let bearerToken = null; + if (typeof options.acquireProviderToken === 'function') { + bearerToken = await options.acquireProviderToken(options.channel); + } + return { mode: 'oauth2', token: bearerToken }; + } + + const [secret, identity] = await Promise.all([ + resolveSecretValue(authConfiguration.keyVaultSecretName), + authConfiguration.identityKeyVaultSecretName + ? resolveSecretValue(authConfiguration.identityKeyVaultSecretName) + : Promise.resolve(''), + ]); + return { mode: 'apiKey', secret, identity }; +} + +// ─── Endpoint & outcome mapping ────────────────────────────────────────────────── + +// Base URL from app settings (UX-provisioned): _ENDPOINT_EUDB when EUDB=true, else _ENDPOINT. +// Endpoints are deployment config, never in the manifest. Undefined if unset → fails the send closed. +function resolveEndpointBaseUrl(manifest, environmentVariables) { + const idUpper = manifest.id.toUpperCase(); + const useEudb = String(environmentVariables.EUDB || '').toLowerCase() === 'true'; + const eudbEndpoint = environmentVariables[`${idUpper}_ENDPOINT_EUDB`]; + if (useEudb && eudbEndpoint) { + return eudbEndpoint; + } + return environmentVariables[`${idUpper}_ENDPOINT`]; +} + +// Translates the provider's parsed status into a normalized outcome. A recognized status wins; an +// unknown status is fail-closed; only a status-less response trusts the HTTP result. +function resolveOutcome(manifest, parsedResponse) { + const responseMapping = manifest.responseMapping || {}; + const providerStatusKey = parsedResponse.providerStatusName || parsedResponse.providerStatusCode; + if (providerStatusKey) { + return responseMapping[providerStatusKey] || responseMapping.default || OUTCOME.FAIL; + } + return parsedResponse.success ? OUTCOME.CONTINUE : (responseMapping.default || OUTCOME.FAIL); +} + +// Translates the outcome into the endpoint HTTP status. Continue 200, Block 403, StepUp 409; a Fail +// surfaces the provider's failure class — 429 rate-limit, 401 auth (its 401/403), 400 other 4xx, else 502. +function outcomeToHttpStatus(outcome, providerHttpStatus) { + switch (outcome) { + case OUTCOME.CONTINUE: + return HTTP_STATUS.OK; + case OUTCOME.BLOCK: + return HTTP_STATUS.FORBIDDEN; + case OUTCOME.STEP_UP: + return HTTP_STATUS.CONFLICT; + case OUTCOME.FAIL: + if (providerHttpStatus === HTTP_STATUS.TOO_MANY_REQUESTS) return HTTP_STATUS.TOO_MANY_REQUESTS; + if (providerHttpStatus === HTTP_STATUS.UNAUTHORIZED || providerHttpStatus === HTTP_STATUS.FORBIDDEN) return HTTP_STATUS.UNAUTHORIZED; + if (providerHttpStatus >= 400 && providerHttpStatus < 500) return HTTP_STATUS.BAD_REQUEST; + return HTTP_STATUS.BAD_GATEWAY; + default: + return HTTP_STATUS.BAD_GATEWAY; + } +} + +// ─── Sending ─────────────────────────────────────────────────────────────────── + +// POSTs the provider request with a hard timeout; throws a descriptive error on timeout/network failure. +async function fetchWithTimeout(providerRequest, timeoutMilliseconds) { + const abortController = new AbortController(); + let timedOut = false; + const timeoutTimer = setTimeout(() => { + timedOut = true; + abortController.abort(); + }, timeoutMilliseconds); + + try { + return await fetch(providerRequest.url, { + method: providerRequest.method || 'POST', + headers: providerRequest.headers, + body: providerRequest.body, + signal: abortController.signal, + }); + } catch (error) { + throw new Error(timedOut ? `endpoint timeout after ${timeoutMilliseconds}ms` : error.message); + } finally { + clearTimeout(timeoutTimer); + } +} + +const errorBody = (providerId, reason, requestId) => + ({ status: RESPONSE_STATUS.ERROR, provider: providerId, reason, requestId }); +const failBody = (providerId, channel, reason, dispatch, requestId) => + ({ status: RESPONSE_STATUS.FAILED, outcome: OUTCOME.FAIL, provider: providerId, channel, reason, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }); + +async function sendViaProvider(providerEntry, dispatch, options) { + const { shutter, context, requestId } = options; + const writeLog = (logMessage) => context && context.log(logMessage); + + const { manifest, adapter } = providerEntry; + const providerId = manifest.id; + const channel = (dispatch.channel || DEFAULTS.CHANNEL).toLowerCase(); + + if (!(manifest.channels || DEFAULTS.CHANNELS).includes(channel)) { + writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} not supported`); + return { httpStatus: HTTP_STATUS.BAD_REQUEST, body: errorBody(providerId, `channel '${channel}' not supported`, requestId) }; + } + + // Resolve the outbound credential; fail closed (502) if missing — this is our credential, not the + // caller's token. A declared identity secret is also required. + let credential = null; + try { + credential = await resolveProviderCredential(manifest.auth, { channel, acquireProviderToken: options.acquireProviderToken }); + } catch (error) { + writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} credential error=${error.message}`); + } + const identityRequired = credential && credential.mode === 'apiKey' && !!manifest.auth.identityKeyVaultSecretName; + const credentialUnavailable = !credential + || (credential.mode === 'oauth2' && !credential.token) + || (credential.mode === 'apiKey' && !credential.secret) + || (identityRequired && !credential.identity); + if (credentialUnavailable) { + writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} provider credential unavailable`); + return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; + } + + const endpointBaseUrl = resolveEndpointBaseUrl(manifest, process.env); + if (!endpointBaseUrl) { + writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} endpoint not configured`); + return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider endpoint not configured', dispatch, requestId) }; + } + + const providerRequest = adapter.buildRequest({ + channel, + endpoint: endpointBaseUrl, + dispatch, + credential, + env: process.env, + }); + + writeLog(`[DISPATCH] requestId=${requestId} provider=${providerId} channel=${channel} correlationId=${dispatch.correlationId} shutter=${!!shutter}`); + + // Shutter mode: everything runs except the actual send. + if (shutter) { + writeLog(`[SHUTTER] requestId=${requestId} provider=${providerId} channel=${channel} processed but NOT sending`); + return { + httpStatus: HTTP_STATUS.OK, + body: { status: RESPONSE_STATUS.ACCEPTED, shutterProcessed: true, provider: providerId, channel, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }, + }; + } + + // Endpoint timeout is a provisioned app setting (UX sets ENDPOINT_TIMEOUT_MS). + const timeoutMilliseconds = Number(process.env.ENDPOINT_TIMEOUT_MS) || DEFAULTS.ENDPOINT_TIMEOUT_MILLISECONDS; + let providerResponse; + try { + providerResponse = await fetchWithTimeout(providerRequest, timeoutMilliseconds); + } catch (error) { + const isTimeout = typeof error.message === 'string' && error.message.startsWith('endpoint timeout'); + const httpStatus = isTimeout ? HTTP_STATUS.GATEWAY_TIMEOUT : HTTP_STATUS.BAD_GATEWAY; + writeLog(`[${isTimeout ? 'DISPATCH_TIMEOUT' : 'DISPATCH_ERROR'}] requestId=${requestId} provider=${providerId} channel=${channel} reason=${error.message}`); + return { httpStatus, body: failBody(providerId, channel, error.message, dispatch, requestId) }; + } + + const responseText = await providerResponse.text(); + let responseJson; + try { + responseJson = JSON.parse(responseText); + } catch { + // Non-JSON body (e.g. an HTML error page): keep it raw so the adapter's parseResponse still runs. + responseJson = { raw: responseText }; + } + + const parsedResponse = adapter.parseResponse({ + channel, + httpStatus: providerResponse.status, + ok: providerResponse.ok, + text: responseText, + json: responseJson, + }); + const outcome = resolveOutcome(manifest, parsedResponse); + const httpStatus = outcomeToHttpStatus(outcome, parsedResponse.providerHttpStatus); + + writeLog(`[DISPATCH_RESULT] requestId=${requestId} provider=${providerId} channel=${channel} outcome=${outcome} providerStatus=${parsedResponse.providerStatusName || parsedResponse.providerStatusCode || 'n/a'} httpStatus=${httpStatus} correlationId=${dispatch.correlationId}`); + + return { + httpStatus, + body: { + status: outcome === OUTCOME.CONTINUE ? RESPONSE_STATUS.ACCEPTED : RESPONSE_STATUS.FAILED, + outcome, + provider: providerId, + channel, + messageId: dispatch.messageId, + correlationId: dispatch.correlationId, + providerMessageId: parsedResponse.providerMessageId || null, + providerStatus: parsedResponse.providerStatusName || parsedResponse.providerStatusCode || null, + providerStatusDescription: parsedResponse.providerStatusDescription || null, + requestId, + }, + }; +} + +// Public entry: resolve the provider, then send. +async function dispatchOtp(dispatch, options) { + const { requestProvider, context, requestId } = options; + const writeLog = (logMessage) => context && context.log(logMessage); + + const providerEntry = resolveProvider(requestProvider); + if (!providerEntry) { + writeLog(`[DISPATCH_ERROR] requestId=${requestId} unknown provider=${requestProvider || 'n/a'}`); + return { + httpStatus: HTTP_STATUS.BAD_REQUEST, + body: { status: RESPONSE_STATUS.ERROR, reason: 'unknown provider', requestId }, + }; + } + return sendViaProvider(providerEntry, dispatch, options); +} + +// Fire-and-forget warm-up at module load. +warmUpSecretCache(); + +module.exports = { + dispatchOtp, + getProvider, + resolveSecretValue, + DEFAULTS, +}; diff --git a/javascript/src/functions/providers/infobip.js b/javascript/src/functions/providers/infobip.js new file mode 100644 index 0000000..d4f91dc --- /dev/null +++ b/javascript/src/functions/providers/infobip.js @@ -0,0 +1,71 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +// Infobip provider: SMS via /sms/3/messages, voice via /tts/3/advanced. +// Auth: App API key (default) or a Bearer token (oauth2 mode). Voice is unverified. + +const manifest = { + id: 'infobip', + auth: { mode: 'apiKey', keyVaultSecretName: 'infobip-api-key' }, + // Infobip status groups (groupName): ACCEPTED/PENDING/DELIVERED = success, REJECTED/EXPIRED/UNDELIVERABLE = fail. + responseMapping: { + ACCEPTED: 'Continue', + PENDING: 'Continue', + DELIVERED: 'Continue', + REJECTED: 'Fail', + EXPIRED: 'Fail', + UNDELIVERABLE: 'Fail', + default: 'Fail', + }, +}; + +function buildRequest({ channel, endpoint, dispatch, credential, env }) { + const base = endpoint; + const senderId = env.INFOBIP_SENDER_ID || 'Verify'; + const authorization = credential.mode === 'oauth2' ? `Bearer ${credential.token}` : `App ${credential.secret}`; + const headers = { + Authorization: authorization, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + if (channel === 'voice') { + const body = { + messages: [{ + from: env.INFOBIP_VOICE_FROM || senderId, + destinations: [{ to: dispatch.destination, messageId: dispatch.correlationId || dispatch.messageId }], + text: dispatch.message, + language: dispatch.locale || 'en', + voice: { name: 'Joanna', gender: 'female' }, + }], + }; + return { url: `${base}/tts/3/advanced`, method: 'POST', headers, body: JSON.stringify(body) }; + } + + const body = { + messages: [{ + sender: senderId, + destinations: [{ to: dispatch.destination, messageId: dispatch.correlationId || dispatch.messageId }], + content: { text: dispatch.message }, + }], + }; + return { url: `${base}/sms/3/messages`, method: 'POST', headers, body: JSON.stringify(body) }; +} + +function parseResponse({ httpStatus, ok, json }) { + const firstMessage = json && json.messages && json.messages[0]; + const status = (firstMessage && firstMessage.status) || {}; + return { + success: ok, + providerHttpStatus: httpStatus, + providerMessageId: (firstMessage && firstMessage.messageId) || null, + providerStatusName: (status.groupName || status.name || '').toUpperCase() || null, + providerStatusDescription: status.description || null, + providerResponse: json, + }; +} + +module.exports = { manifest, buildRequest, parseResponse }; diff --git a/javascript/src/functions/providers/sinch.js b/javascript/src/functions/providers/sinch.js new file mode 100644 index 0000000..8fec32f --- /dev/null +++ b/javascript/src/functions/providers/sinch.js @@ -0,0 +1,70 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +// Sinch provider: SMS via XMS Batches (POST /xms/v1/{plan}/batches, Bearer). +// Voice via the Calling TTS callout API (best-effort; Sinch Voice usually uses app-signed auth). +// Delivery status is async: XMS returns a batch/call id, not a final status. + +const manifest = { + id: 'sinch', + auth: { mode: 'apiKey', keyVaultSecretName: 'sinch-api-token' }, + responseMapping: { + Dispatched: 'Continue', + Delivered: 'Continue', + Queued: 'Continue', + Failed: 'Fail', + Rejected: 'Fail', + default: 'Fail', + }, +}; + +function buildRequest({ channel, endpoint, dispatch, credential, env }) { + const bearerToken = credential.mode === 'oauth2' ? credential.token : credential.secret; + const headers = { + Authorization: `Bearer ${bearerToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + if (channel === 'voice') { + // Sinch Voice uses its own host (and normally app-signed auth, not the XMS token — verify). + const voiceBase = env.SINCH_VOICE_ENDPOINT || 'https://calling.api.sinch.com'; + const body = { + method: 'ttsCallout', + ttsCallout: { + destination: { type: 'number', endpoint: dispatch.destination }, + text: dispatch.message, + locale: dispatch.locale || 'en-US', + custom: dispatch.correlationId || dispatch.messageId, + }, + }; + return { url: `${voiceBase}/calling/v1/callouts`, method: 'POST', headers, body: JSON.stringify(body) }; + } + + const smsBase = endpoint; + const servicePlanId = env.SINCH_SERVICE_PLAN_ID || ''; + const body = { + from: env.SINCH_SENDER_ID || 'Verify', + to: [dispatch.destination], + body: dispatch.message, + client_reference: dispatch.correlationId || dispatch.messageId, + }; + return { url: `${smsBase}/xms/v1/${servicePlanId}/batches`, method: 'POST', headers, body: JSON.stringify(body) }; +} + +function parseResponse({ httpStatus, ok, json }) { + const messageOrCallId = (json && (json.id || json.callId || json._links && json._links.self)) || null; + return { + success: ok, + providerHttpStatus: httpStatus, + providerMessageId: typeof messageOrCallId === 'string' ? messageOrCallId : (messageOrCallId && messageOrCallId.href) || null, + providerStatusName: ok ? 'Dispatched' : (json && (json.text || json.status)) || null, + providerStatusDescription: (json && (json.text || json.detailedStatus)) || null, + providerResponse: json, + }; +} + +module.exports = { manifest, buildRequest, parseResponse }; diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js new file mode 100644 index 0000000..9cac4de --- /dev/null +++ b/javascript/src/functions/providers/soprano.js @@ -0,0 +1,90 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +// Soprano provider: Soprano Connect API (MEMS): POST {base}/messages/{sms|voice}; base is https:///cgpapi. +// Auth: X-MEMS-API-ID + X-MEMS-API-Key headers (both from Key Vault), or a Bearer JWT. +// Sender is a provisioned source endpoint (SOPRANO_SOURCE_ID), not a free-text string. Verified live (HTTP 201, ENROUTE). + +const manifest = { + id: 'soprano', + auth: { + mode: 'apiKey', + keyVaultSecretName: 'soprano-api-key', + identityKeyVaultSecretName: 'soprano-api-id', + }, + responseMapping: { + ENROUTE: 'Continue', + ACCEPTED: 'Continue', + SUBMITTED: 'Continue', + SENT: 'Continue', + DELIVERED: 'Continue', + QUEUED: 'Continue', + FAILED: 'Fail', + REJECTED: 'Fail', + BLOCKED: 'Block', + default: 'Fail', + }, +}; + +function buildRequest({ channel, endpoint, dispatch, credential, env }) { + const base = endpoint; + const messageType = channel === 'voice' ? 'voice' : 'sms'; + + const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }; + if (credential.mode === 'oauth2') { + headers.Authorization = `Bearer ${credential.token}`; + } else { + headers['X-MEMS-API-ID'] = credential.identity; + headers['X-MEMS-API-Key'] = credential.secret; + } + + const body = { + messageType, + destination: dispatch.destination, + text: dispatch.message, + clientReference: dispatch.correlationId || dispatch.messageId, + }; + // Sender: a provisioned source endpoint (endpoints:[{type,id}]) is what Soprano accepts; free-text source is a fallback. + if (env.SOPRANO_SOURCE_ID) { + body.endpoints = [{ type: Number(env.SOPRANO_SOURCE_TYPE || 1), id: Number(env.SOPRANO_SOURCE_ID) }]; + } else if (env.SOPRANO_SENDER_ID) { + body.source = env.SOPRANO_SENDER_ID; + } + // Voice: Soprano speaks the fully-rendered message via text-to-speech. `language` must be a full + // Nexmo voice code (e.g. en-US), not a bare `en`. + if (messageType === 'voice') { + const voiceLanguage = env.SOPRANO_VOICE_LANGUAGE + || (dispatch.locale && dispatch.locale.includes('-') ? dispatch.locale : 'en-US'); + delete body.text; + body.voice = { + text2voice: { + beforePasswordText: dispatch.message || '', + password: '', + afterPasswordText: '', + language: voiceLanguage, + gender: Number(env.SOPRANO_VOICE_GENDER || 1), + loop: 1, + }, + }; + } + + return { url: `${base}/messages/${messageType}`, method: 'POST', headers, body: JSON.stringify(body) }; +} + +function parseResponse({ httpStatus, ok, json }) { + const payload = (Array.isArray(json) ? json[0] : json) || {}; + const status = (payload.status || payload.state || '').toString().toUpperCase() || (ok ? 'SUBMITTED' : null); + return { + success: ok, + providerHttpStatus: httpStatus, + providerMessageId: (payload.id != null ? String(payload.id) : null) || payload.messageId || null, + providerStatusName: status, + providerStatusDescription: payload.errorDescription || payload.statusText || payload.description || null, + providerResponse: json, + }; +} + +module.exports = { manifest, buildRequest, parseResponse }; diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js new file mode 100644 index 0000000..4552b29 --- /dev/null +++ b/javascript/src/functions/providers/telesign.js @@ -0,0 +1,89 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +// Telesign provider: SMS via /v1/messaging, voice via /v1/voice (form-urlencoded). +// Auth: HTTP Basic (customer_id:api_key), or a Bearer token in oauth2 mode. +// Both customer_id and api_key come from Key Vault (rotate either by replacing the secret value — no redeploy). + +const manifest = { + id: 'telesign', + auth: { + mode: 'apiKey', + keyVaultSecretName: 'telesign-api-key', + identityKeyVaultSecretName: 'telesign-customer-id', + }, + // SMS: 200/203 delivered, 290/291/292 in progress. Voice: 100 answered, 101/102/103 placed/ringing/in progress. + responseMapping: { + 200: 'Continue', + 203: 'Continue', + 290: 'Continue', + 291: 'Continue', + 292: 'Continue', + 100: 'Continue', + 101: 'Continue', + 102: 'Continue', + 103: 'Continue', + default: 'Fail', + }, +}; + +function buildRequest({ channel, endpoint, dispatch, credential, env }) { + const base = endpoint; + const contentType = 'application/x-www-form-urlencoded'; + + const authorization = credential.mode === 'oauth2' + ? `Bearer ${credential.token}` + : `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`; + + let path; + let params; + if (channel === 'voice') { + path = '/v1/voice'; + params = new URLSearchParams({ + phone_number: dispatch.destination, + message: dispatch.message, + message_type: 'OTP', + voice: env.TELESIGN_VOICE || 'f-en-US', + external_id: dispatch.correlationId || dispatch.messageId, + }); + } else { + path = '/v1/messaging'; + params = new URLSearchParams({ + phone_number: dispatch.destination, + message: dispatch.message, + sender_id: env.TELESIGN_SENDER_ID || '', + message_type: 'OTP', + external_id: dispatch.correlationId || dispatch.messageId, + is_primary: 'true', + }); + } + + return { + url: `${base}${path}`, + method: 'POST', + headers: { + Authorization: authorization, + 'Content-Type': contentType, + Accept: 'application/json', + }, + body: params.toString(), + }; +} + +function parseResponse({ httpStatus, ok, json }) { + const status = (json && json.status) || {}; + return { + success: ok, + providerHttpStatus: httpStatus, + providerMessageId: (json && json.reference_id) || null, + providerStatusCode: status.code != null ? String(status.code) : null, + providerStatusName: null, + providerStatusDescription: status.description || null, + providerResponse: json, + }; +} + +module.exports = { manifest, buildRequest, parseResponse }; diff --git a/javascript/src/functions/security.js b/javascript/src/functions/security.js new file mode 100644 index 0000000..edf4ee5 --- /dev/null +++ b/javascript/src/functions/security.js @@ -0,0 +1,57 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +// Endpoint security: optional inbound bearer-token validation — anonymous unless REQUIRE_AUTH=true, +// which validates a Microsoft JWT (iss/aud/exp/RS256). 'jose' is lazy-loaded. + +const jwksByTenant = new Map(); + +// Gets (and caches per issuer tenant) the remote JWKS used to verify inbound tokens. +function getJwks(issuerTenantId) { + if (!jwksByTenant.has(issuerTenantId)) { + const { createRemoteJWKSet } = require('jose'); + jwksByTenant.set( + issuerTenantId, + createRemoteJWKSet(new URL(`https://login.microsoftonline.com/${issuerTenantId}/discovery/v2.0/keys`)), + ); + } + return jwksByTenant.get(issuerTenantId); +} + +// Validates the inbound bearer token when REQUIRE_AUTH is enabled (issuer, audience, expiry, RS256). +async function validateToken(request, context, requestId) { + if (String(process.env.REQUIRE_AUTH || 'false').toLowerCase() !== 'true') { + return { ok: true, skipped: true }; + } + + const audience = process.env.EXPECTED_AUDIENCE; + const tenantId = process.env.ISSUER_TENANT_ID; + if (!audience || !tenantId) { + return { ok: false, reason: 'REQUIRE_AUTH is set but EXPECTED_AUDIENCE / ISSUER_TENANT_ID are missing' }; + } + + const authorizationHeader = (request.headers.get('authorization') || '').trim(); + const bearerToken = authorizationHeader.slice(0, 7).toLowerCase() === 'bearer ' + ? authorizationHeader.slice(7).trim() + : ''; + if (!bearerToken) return { ok: false, reason: 'missing bearer token' }; + + try { + const { jwtVerify } = require('jose'); + const issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`; + await jwtVerify(bearerToken, getJwks(tenantId), { + audience, + issuer, + algorithms: ['RS256'], + }); + return { ok: true }; + } catch (error) { + context.log(`[AUTH_FAIL] requestId=${requestId} reason=${error.message}`); + return { ok: false, reason: 'token validation failed' }; + } +} + +module.exports = { validateToken }; diff --git a/javascript/test/auth.test.js b/javascript/test/auth.test.js new file mode 100644 index 0000000..6639658 --- /dev/null +++ b/javascript/test/auth.test.js @@ -0,0 +1,45 @@ +'use strict'; + +const { test, afterEach } = require('node:test'); +const assert = require('node:assert'); +const { validateToken } = require('../src/functions/security'); + +const ctx = { log() {} }; +const reqWith = (headers = {}) => ({ headers: { get: (k) => headers[k.toLowerCase()] || null } }); + +afterEach(() => { + delete process.env.REQUIRE_AUTH; + delete process.env.EXPECTED_AUDIENCE; + delete process.env.ISSUER_TENANT_ID; +}); + +test('skips validation when REQUIRE_AUTH is not true', async () => { + const r = await validateToken(reqWith(), ctx, 'r'); + assert.equal(r.ok, true); + assert.equal(r.skipped, true); +}); + +test('fails when REQUIRE_AUTH=true but audience/tenant are missing', async () => { + process.env.REQUIRE_AUTH = 'true'; + const r = await validateToken(reqWith(), ctx, 'r'); + assert.equal(r.ok, false); + assert.match(r.reason, /EXPECTED_AUDIENCE|ISSUER_TENANT_ID/); +}); + +test('fails when the bearer token is missing', async () => { + process.env.REQUIRE_AUTH = 'true'; + process.env.EXPECTED_AUDIENCE = 'aud'; + process.env.ISSUER_TENANT_ID = 'tid'; + const r = await validateToken(reqWith(), ctx, 'r'); + assert.equal(r.ok, false); + assert.equal(r.reason, 'missing bearer token'); +}); + +test('fails (generic reason) on an invalid token', async () => { + process.env.REQUIRE_AUTH = 'true'; + process.env.EXPECTED_AUDIENCE = 'aud'; + process.env.ISSUER_TENANT_ID = 'tid'; + const r = await validateToken(reqWith({ authorization: 'Bearer not-a-jwt' }), ctx, 'r'); + assert.equal(r.ok, false); + assert.equal(r.reason, 'token validation failed'); +}); diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js new file mode 100644 index 0000000..f8e4578 --- /dev/null +++ b/javascript/test/dispatch.test.js @@ -0,0 +1,195 @@ +'use strict'; + +// Integration tests for the dispatch pipeline with a mocked provider fetch and a mocked Key Vault. + +const { test, beforeEach, mock } = require('node:test'); +const assert = require('node:assert'); + +// Non-secret provider config (app settings, not secrets) — set before requiring the modules. +process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; +process.env.SINCH_SERVICE_PLAN_ID = 'sp'; +process.env.INFOBIP_ENDPOINT = 'https://api.infobip.com'; +process.env.TELESIGN_ENDPOINT = 'https://rest-api.telesign.com'; +process.env.SINCH_ENDPOINT = 'https://sms.api.sinch.com'; +process.env.SOPRANO_ENDPOINT = 'https://mems.example.com/cgpapi'; + +// Provider secrets come from Key Vault via managed identity in production; mock getSecret here. +const providerSecrets = { + 'infobip-api-key': 'ib', + 'telesign-api-key': 'ts', + 'telesign-customer-id': 'cust', + 'sinch-api-token': 'st', + 'soprano-api-key': 'sp', + 'soprano-api-id': 'sp-id', +}; +const { SecretClient } = require('@azure/keyvault-secrets'); +mock.method(SecretClient.prototype, 'getSecret', async (name) => ({ value: providerSecrets[name] })); + +const { dispatchOtp, getProvider } = require('../src/functions/dispatch'); + +let resp; +let sent; +global.fetch = async (url, opts) => { + sent = { url, opts }; + if (resp === 'THROW') throw new Error('neterr'); + if (resp === 'TIMEOUT') throw new Error('endpoint timeout after 1500ms'); + return { ok: resp.ok, status: resp.status, text: async () => JSON.stringify(resp.body) }; +}; + +const ctx = { log() {} }; +let n = 0; +const uniqueDest = () => '+1555' + String(1000000 + n++).slice(-7); +const disp = (o = {}) => ({ destination: uniqueDest(), message: 'Your code is 918273', channel: 'sms', messageId: 'm', correlationId: 'c' + Math.random(), ...o }); + +beforeEach(() => { + resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'DELIVERED' } }] } }; +}); + +// A "success" response body shaped the way each provider's parseResponse expects, so each yields a +// status that maps to Continue (unknown statuses now fail closed — see resolveOutcome). +const successBody = { + infobip: { messages: [{ status: { name: 'DELIVERED' } }] }, + telesign: { status: { code: 290 } }, + sinch: { id: 'batch-1' }, + soprano: { status: 'DELIVERED' }, +}; + +for (const prov of ['infobip', 'telesign', 'sinch', 'soprano']) { + for (const ch of ['sms', 'voice']) { + test(`${prov}/${ch}: 200, message sent in body, https, provider auth scheme`, async () => { + resp = { ok: true, status: 200, body: successBody[prov] }; + const r = await dispatchOtp(disp({ channel: ch }), { tenantId: 't', requestProvider: prov, context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 200); + assert.match(sent.url, /^https:\/\//); + assert.ok(sent.opts.body.includes('918273'), 'rendered message missing from body'); + const providerAuth = sent.opts.headers.Authorization || sent.opts.headers['X-MEMS-API-Key']; + assert.ok(providerAuth, 'provider auth header missing'); + if (sent.opts.headers.Authorization) { + assert.match(sent.opts.headers.Authorization, /Bearer|App|Basic/); + } + }); + } +} + +test('Block outcome maps to 403', async () => { + resp = { ok: false, status: 200, body: { status: 'BLOCKED' } }; + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'soprano', context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 403); +}); + +test('Fail outcome maps to the provider HTTP status (400)', async () => { + resp = { ok: false, status: 400, body: { messages: [{ status: { name: 'REJECTED' } }] } }; + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 400); + assert.equal(r.body.outcome, 'Fail'); +}); + +test('Fail with provider 429 maps to 429 (rate limit)', async () => { + resp = { ok: false, status: 429, body: { messages: [{ status: { name: 'REJECTED' } }] } }; + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 429); + assert.equal(r.body.outcome, 'Fail'); +}); + +test('Fail with provider 401/403 maps to 401 (auth)', async () => { + resp = { ok: false, status: 403, body: { messages: [{ status: { name: 'REJECTED' } }] } }; + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 401); + assert.equal(r.body.outcome, 'Fail'); +}); + +test('provider HTTP 200 with a REJECTED status still maps to Fail (not Continue)', async () => { + resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'REJECTED' } }] } }; + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + assert.equal(r.body.outcome, 'Fail'); + assert.equal(r.body.status, 'failed'); +}); + +test('provider HTTP 200 with a BLOCKED status maps to Block (403)', async () => { + resp = { ok: true, status: 200, body: { status: 'BLOCKED' } }; + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'soprano', context: ctx, requestId: 'r' }); + assert.equal(r.body.outcome, 'Block'); + assert.equal(r.httpStatus, 403); +}); + +test('endpoint timeout maps to 504', async () => { + resp = 'TIMEOUT'; + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 504); + assert.equal(r.body.outcome, 'Fail'); +}); + +test('network error (non-timeout) maps to 502', async () => { + resp = 'THROW'; + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 502); + assert.equal(r.body.outcome, 'Fail'); +}); + +test('unknown provider status fails closed even on HTTP 200 (§15)', async () => { + resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'WATWATWAT' } }] } }; + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + assert.equal(r.body.outcome, 'Fail'); + assert.equal(r.body.status, 'failed'); +}); + +test('apiKey mode fails closed when the secret is missing (502)', async () => { + const manifest = getProvider('soprano').manifest; + const saved = JSON.parse(JSON.stringify(manifest.auth)); + manifest.auth = { mode: 'apiKey', keyVaultSecretName: '__missing_secret__' }; + try { + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'soprano', context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 502); + assert.equal(r.body.reason, 'provider credential unavailable'); + } finally { + manifest.auth = saved; + } +}); + +test('shutter returns 200 without sending', async () => { + let calls = 0; + const orig = global.fetch; + global.fetch = async (...a) => { calls++; return orig(...a); }; + try { + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', shutter: true, context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 200); + assert.equal(r.body.shutterProcessed, true); + assert.equal(calls, 0); + } finally { + global.fetch = orig; + } +}); + +test('unknown provider is rejected (400)', async () => { + const r = await dispatchOtp(disp(), { requestProvider: 'nope', context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 400); +}); + +test('oauth2 mode uses a Bearer token and fails closed without one', async () => { + const manifest = getProvider('sinch').manifest; + const saved = JSON.parse(JSON.stringify(manifest.auth)); + manifest.auth.mode = 'oauth2'; + try { + await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'sinch', context: ctx, requestId: 'r', acquireProviderToken: async () => 'TKN' }); + assert.equal(sent.opts.headers.Authorization, 'Bearer TKN'); + + const noToken = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'sinch', context: ctx, requestId: 'r' }); + assert.equal(noToken.httpStatus, 502); // credential unavailable → 502, not 401 + } finally { + manifest.auth = saved; + } +}); + +test('apiKey provider that needs an identity fails closed when the identity secret is missing (502)', async () => { + const manifest = getProvider('telesign').manifest; + const saved = JSON.parse(JSON.stringify(manifest.auth)); + manifest.auth.identityKeyVaultSecretName = '__missing_identity__'; + try { + const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'telesign', context: ctx, requestId: 'r' }); + assert.equal(r.httpStatus, 502); + assert.equal(r.body.reason, 'provider credential unavailable'); + } finally { + manifest.auth = saved; + } +}); + diff --git a/javascript/test/security.test.js b/javascript/test/security.test.js new file mode 100644 index 0000000..55a9ead --- /dev/null +++ b/javascript/test/security.test.js @@ -0,0 +1,44 @@ +'use strict'; + +// Privacy assertion: the OTP code and phone number must never appear in logs or the response body. +// (They necessarily appear in the outbound provider request — that is the delivery itself.) + +const { test, mock } = require('node:test'); +const assert = require('node:assert'); + +// The provider secret comes from Key Vault via managed identity in production; mock getSecret here. +process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; +process.env.INFOBIP_ENDPOINT = 'https://api.infobip.com'; +const { SecretClient } = require('@azure/keyvault-secrets'); +mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'ib' })); + +const { dispatchOtp } = require('../src/functions/dispatch'); + +const CODE = '918273'; +const PHONE = '+15551234567'; + +global.fetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ messages: [{ status: { name: 'DELIVERED' }, messageId: 'x' }] }), +}); + +test('OTP code and phone never appear in logs or the response body', async () => { + const logs = []; + const ctx = { log: (m) => logs.push(String(m)) }; + + const r = await dispatchOtp( + { destination: PHONE, message: `Your code is ${CODE}`, channel: 'sms', messageId: 'm', correlationId: 'c' }, + { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }, + ); + assert.equal(r.httpStatus, 200); + + for (const line of logs) { + assert.ok(!line.includes(CODE), `code leaked in a log line: ${line}`); + assert.ok(!line.includes(PHONE), `phone leaked in a log line: ${line}`); + } + + const bodyStr = JSON.stringify(r.body); + assert.ok(!bodyStr.includes(CODE), 'code leaked in response body'); + assert.ok(!bodyStr.includes(PHONE), 'phone leaked in response body'); +}); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js new file mode 100644 index 0000000..1e8eb78 --- /dev/null +++ b/javascript/test/sendotp.test.js @@ -0,0 +1,155 @@ +'use strict'; + +// Tests for the SendOtp HTTP handler — the SAS → CYOT envelope: validation, JWE decryption round-trip, +// the happy path (nonce echo), Evaluation mode, and auth rejection. Handlers are captured by stubbing +// @azure/functions; the JWE is encrypted here with a throwaway RSA key that the handler decrypts via +// CYOT_JWE_PRIVATE_KEY_PEM. + +const { test, mock } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const Module = require('module'); +const { CompactEncrypt } = require('jose'); + +// Throwaway RSA keypair: the handler decrypts with the private PEM from the environment. +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +process.env.CYOT_JWE_PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); +process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; +process.env.INFOBIP_ENDPOINT = 'https://api.infobip.com'; +process.env.DEFAULT_PROVIDER = 'infobip'; + +const { SecretClient } = require('@azure/keyvault-secrets'); +mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'ib' })); + +// Capture the handlers SendOtp registers via app.http(...) by stubbing @azure/functions during require. +const handlers = {}; +const originalLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (request === '@azure/functions') { + return { app: { http: (name, opts) => { handlers[name] = opts.handler; } } }; + } + return originalLoad.apply(this, arguments); +}; +require('../src/functions/SendOtp'); +Module._load = originalLoad; + +const ctx = { log() {} }; + +const makeReq = (body, headers = {}) => ({ + method: 'POST', + url: 'http://localhost/api/SendOtp', + headers: { get: (k) => headers[String(k).toLowerCase()] ?? null }, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), +}); + +async function encryptContext(context, kid = 'test-key') { + return new CompactEncrypt(Buffer.from(JSON.stringify(context))) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', kid }) + .encrypt(publicKey); +} + +const sampleContext = () => ({ + nonce: 'nonce-abc', + phoneNumber: '+14255551234', + locale: 'en-US', + message: 'Your code is 1 2 3 4 5 6', +}); + +async function makeEnvelope(overrides = {}, context = sampleContext()) { + return { + type: 'microsoft.mfa.otpDeliver.v1', + tenantId: 'tenant-1', + correlationId: 'corr-1', + channel: 1, + mode: 1, + ttlSeconds: 60, + encryptedDeliveryContext: await encryptContext(context), + ...overrides, + }; +} + +let sent; +global.fetch = async (url, opts) => { + sent = { url, opts }; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ messages: [{ status: { groupName: 'PENDING' }, messageId: 'x' }] }), + }; +}; + +test('SendOtp: invalid JSON body -> 400', async () => { + const r = await handlers.SendOtp(makeReq('{ not json'), ctx); + assert.equal(r.status, 400); + assert.equal(r.jsonBody.error, 'bad_request'); +}); + +test('SendOtp: missing encryptedDeliveryContext -> 400', async () => { + const r = await handlers.SendOtp(makeReq({ type: 'v1', channel: 1, mode: 1 }), ctx); + assert.equal(r.status, 400); + assert.match(r.jsonBody.reason, /encryptedDeliveryContext/); +}); + +test('SendOtp: unsupported channel -> 400', async () => { + const r = await handlers.SendOtp(makeReq(await makeEnvelope({ channel: 9 })), ctx); + assert.equal(r.status, 400); + assert.match(r.jsonBody.reason, /channel/); +}); + +test('SendOtp: unsupported mode -> 400', async () => { + const r = await handlers.SendOtp(makeReq(await makeEnvelope({ mode: 5 })), ctx); + assert.equal(r.status, 400); + assert.match(r.jsonBody.reason, /mode/); +}); + +test('SendOtp: undecryptable context -> 400 decryption_failed', async () => { + const r = await handlers.SendOtp(makeReq(await makeEnvelope({ encryptedDeliveryContext: 'eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIn0.bad.bad.bad.bad' })), ctx); + assert.equal(r.status, 400); + assert.equal(r.jsonBody.error, 'decryption_failed'); +}); + +test('SendOtp: incomplete context (no phoneNumber) -> 400', async () => { + const r = await handlers.SendOtp(makeReq(await makeEnvelope({}, { nonce: 'n', message: 'm' })), ctx); + assert.equal(r.status, 400); + assert.match(r.jsonBody.reason, /incomplete/); +}); + +test('SendOtp: Live with ttlSeconds <= 0 -> 400 request_expired', async () => { + const r = await handlers.SendOtp(makeReq(await makeEnvelope({ ttlSeconds: 0 })), ctx); + assert.equal(r.status, 400); + assert.equal(r.jsonBody.error, 'request_expired'); +}); + +test('SendOtp: valid Live envelope -> 202 accepted, nonce echoed, sent over https', async () => { + sent = undefined; + const r = await handlers.SendOtp(makeReq(await makeEnvelope()), ctx); + assert.equal(r.status, 202); + assert.equal(r.jsonBody.providerStatus, 'accepted'); + assert.equal(r.jsonBody.nonce, 'nonce-abc'); + assert.equal(r.jsonBody.correlationId, 'corr-1'); + assert.match(sent.url, /^https:\/\//); +}); + +test('SendOtp: Evaluation mode -> 202 nonce echoed, nothing sent', async () => { + let calls = 0; + const original = global.fetch; + global.fetch = async (...a) => { calls++; return original(...a); }; + try { + const r = await handlers.SendOtp(makeReq(await makeEnvelope({ mode: 2 })), ctx); + assert.equal(r.status, 202); + assert.equal(r.jsonBody.nonce, 'nonce-abc'); + assert.equal(calls, 0); + } finally { + global.fetch = original; + } +}); + +test('SendOtp: REQUIRE_AUTH enabled but misconfigured -> 401', async () => { + process.env.REQUIRE_AUTH = 'true'; + try { + const r = await handlers.SendOtp(makeReq(await makeEnvelope(), { authorization: 'Bearer abc' }), ctx); + assert.equal(r.status, 401); + } finally { + delete process.env.REQUIRE_AUTH; + } +}); diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..34f5644 --- /dev/null +++ b/python/README.md @@ -0,0 +1,46 @@ +# CYOT Function — Python (v2 model) + +A Python implementation of the CYOT OTP-delivery Function, conforming to the shared +[contract](../docs/CONTRACT.md). Same design as the [`javascript/`](../javascript/) and +[`dotnet/`](../dotnet/) versions: one dispatch engine + drop-in provider adapters, env-provisioned +config, secrets in Key Vault. + +## Layout + +``` +python/ +├─ function_app.py # HTTP trigger: POST /api/SendOtp (v2 model) +├─ requirements.txt +├─ src/ +│ ├─ cyot.py # envelope parse/validate + JWE decrypt + context → dispatch +│ ├─ dispatch.py # resolve provider → credential → endpoint → send → outcome +│ ├─ registry.py # adapter registry + DEFAULT_PROVIDER resolution +│ ├─ providers/*.py # infobip, telesign, soprano, sinch (manifest + build/parse) +│ ├─ secrets.py # Key Vault via managed identity (cached) +│ ├─ outcome.py # status → outcome → HTTP status +│ ├─ models.py # DispatchRequest + outcome constants +│ └─ security.py # Entra JWT validation when REQUIRE_AUTH=true +└─ tests/ # pytest conformance tests +``` + +## Build, test, run + +```bash +cd python +python -m venv .venv && .venv\Scripts\activate # (macOS/Linux: source .venv/bin/activate) +pip install -r requirements.txt pytest +python -m pytest tests # run conformance tests +func start # run locally (copy ../docs/local.settings.sample.json) +``` + +## Deploy + +```bash +func azure functionapp publish # Linux Python Function App +``` + +The app's **managed identity** needs the **Key Vault Secrets User** role on the vault. Configuration +(env var names, Key Vault secret names, behaviors) is identical to the contract — see +[`../docs/CONTRACT.md`](../docs/CONTRACT.md). + +Target: Azure Functions Python **v2** programming model (Python 3.11), Functions v4. diff --git a/python/function_app.py b/python/function_app.py new file mode 100644 index 0000000..466494a --- /dev/null +++ b/python/function_app.py @@ -0,0 +1,101 @@ +"""CYOT OTP Function — Python v2 model. HTTP trigger: POST /api/SendOtp — the SAS → CYOT delivery +endpoint. Validates the token, parses the cleartext routing envelope, decrypts the JWE delivery context +(PII lives there), dispatches, and echoes the nonce to prove decryption. +Privacy: phone and OTP code are never logged or returned in the body.""" +import json +import logging +import os +import uuid + +import azure.functions as func + +from src.cyot import ( + MODE_EVALUATION, + context_to_dispatch, + decrypt_delivery_context, + make_key_provider, + parse_envelope, +) +from src.dispatch import DispatchEngine +from src.providers.infobip import InfobipProvider +from src.providers.sinch import SinchProvider +from src.providers.soprano import SopranoProvider +from src.providers.telesign import TelesignProvider +from src.registry import ProviderRegistry +from src.secrets import SecretResolver +from src.security import validate_token + +app = func.FunctionApp() + +_registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) +_secrets = SecretResolver() +_engine = DispatchEngine(_registry, _secrets) +_key_provider = make_key_provider(os.environ, _secrets) + + +def _json(status_code, body): + return func.HttpResponse(json.dumps(body), status_code=status_code, mimetype="application/json") + + +@app.route(route="SendOtp", methods=["POST"], auth_level=func.AuthLevel.ANONYMOUS) +def send_otp(req: func.HttpRequest) -> func.HttpResponse: + request_id = uuid.uuid4().hex + client_request_id = req.headers.get("x-ms-client-request-id") or request_id + header_correlation_id = req.headers.get("x-ms-correlation-id") + + auth_ok, reason, caller_object_id = validate_token(req.headers.get("Authorization")) + if not auth_ok: + logging.warning("[AUTH_ERROR] requestId=%s reason=%s", request_id, reason) + return _json(401, {"error": "unauthorized", "reason": reason, "requestId": request_id}) + + try: + payload = req.get_json() + except ValueError: + logging.warning("[ERROR] requestId=%s invalid JSON body", request_id) + return _json(400, {"error": "bad_request", "reason": "invalid JSON body", "requestId": request_id}) + + envelope, error = parse_envelope(payload) + if error: + logging.warning("[VALIDATION_ERROR] requestId=%s %s", request_id, error) + return _json(400, {"error": "bad_request", "reason": error, "requestId": request_id}) + + correlation_id = envelope["correlation_id"] or header_correlation_id or request_id + + try: + delivery = decrypt_delivery_context(envelope["encrypted_delivery_context"], _key_provider) + except Exception as err: + logging.warning("[DECRYPT_ERROR] requestId=%s correlationId=%s reason=%s", request_id, correlation_id, err) + return _json(400, {"error": "decryption_failed", "correlationId": correlation_id, "requestId": request_id}) + + if not delivery or not delivery.get("nonce") or not delivery.get("phoneNumber") or not delivery.get("message"): + logging.warning("[VALIDATION_ERROR] requestId=%s correlationId=%s incomplete delivery context", request_id, correlation_id) + return _json(400, {"error": "bad_request", "reason": "incomplete delivery context", "correlationId": correlation_id, "requestId": request_id}) + + nonce = delivery["nonce"] + evaluation = envelope["mode"] == MODE_EVALUATION + + # Respect ttlSeconds: don't start a live delivery for an already-expired passcode (contract §7). + ttl_seconds = envelope["ttl_seconds"] + if not evaluation and isinstance(ttl_seconds, (int, float)) and not isinstance(ttl_seconds, bool) and ttl_seconds <= 0: + logging.warning("[EXPIRED] requestId=%s correlationId=%s ttl=%s", request_id, correlation_id, ttl_seconds) + return _json(400, {"error": "request_expired", "correlationId": correlation_id, "requestId": request_id}) + + logging.info( + "[SENDOTP] requestId=%s caller=%s type=%s tenant=%s correlationId=%s channel=%s mode=%s phone=present message=present risk=%s", + request_id, caller_object_id or "n/a", envelope["type"] or "n/a", envelope["tenant_id"] or "n/a", + correlation_id, envelope["channel"], envelope["mode"], "present" if delivery.get("riskContext") else "absent", + ) + + dispatch = context_to_dispatch(delivery, envelope, client_request_id) + + try: + status_code, _ = _engine.dispatch(dispatch, None, evaluation, request_id, logging) + # Contract: acceptance is 202 Accepted (async delivery); the engine signals acceptance as 200. + accepted = status_code == 200 + return _json( + 202 if accepted else status_code, + {"nonce": nonce, "correlationId": correlation_id, "providerStatus": "accepted" if accepted else "failed"}, + ) + except Exception as error: + logging.error("[EXCEPTION] requestId=%s error=%s", request_id, error) + return _json(500, {"nonce": nonce, "correlationId": correlation_id, "providerStatus": "failed"}) diff --git a/python/host.json b/python/host.json new file mode 100644 index 0000000..06d01bd --- /dev/null +++ b/python/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/python/requirements.txt b/python/requirements.txt new file mode 100644 index 0000000..630ae21 --- /dev/null +++ b/python/requirements.txt @@ -0,0 +1,9 @@ +# Bounded ranges (not exact pins): local/CI resolve against public PyPI, but Azure Functions' +# remote-build mirror is frozen ~early-2025 and can't resolve newer releases. These ranges resolve +# on both. Widen the upper bounds once the Azure build mirror catches up. +azure-functions>=1.21,<1.26 +azure-identity>=1.16,<2 +azure-keyvault-secrets>=4.8,<5 +requests>=2.31,<3 +PyJWT[crypto]>=2.8,<3 +jwcrypto>=1.5,<2 diff --git a/python/src/__init__.py b/python/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/cyot.py b/python/src/cyot.py new file mode 100644 index 0000000..22bf603 --- /dev/null +++ b/python/src/cyot.py @@ -0,0 +1,129 @@ +"""SAS → CYOT envelope handling. SAS sends a cleartext routing envelope (SendCyotOtpRequest) whose PII +(phone + rendered message, which contains the passcode) is encrypted in a JWE. This module parses the +envelope, decrypts the JWE delivery context (RSA-OAEP-256 + A256GCM, key selected by the JOSE `kid`), +and maps the CyotDeliveryContext onto the dispatch engine's request shape.""" +import base64 +import json + +from jwcrypto import jwe as jwe_module +from jwcrypto import jwk + +from .models import DispatchRequest + +# CyotChannel: 1=Sms, 2=Voice (0=Undefined). CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). +CHANNEL_BY_CODE = {1: "sms", 2: "voice"} +CHANNEL_BY_NAME = {"sms": 1, "voice": 2} +MODE_LIVE = 1 +MODE_EVALUATION = 2 +MODE_BY_NAME = {"live": MODE_LIVE, "evaluation": MODE_EVALUATION} + + +def _normalize_channel(channel): + """channel accepts the int enum (1/2) or the string form ('sms'/'voice').""" + if isinstance(channel, bool): + return None + if channel in CHANNEL_BY_CODE: + return channel + if isinstance(channel, str): + return CHANNEL_BY_NAME.get(channel.lower()) + return None + + +def _normalize_mode(mode): + """mode accepts the int enum (1/2) or the string form ('live'/'evaluation').""" + if isinstance(mode, bool): + return None + if mode in (MODE_LIVE, MODE_EVALUATION): + return mode + if isinstance(mode, str): + return MODE_BY_NAME.get(mode.lower()) + return None + + +def parse_envelope(payload): + """Validates + normalizes the cleartext envelope. Returns (envelope, None) or (None, error).""" + if not isinstance(payload, dict): + return None, "invalid envelope" + encrypted = payload.get("encryptedDeliveryContext") + if not isinstance(encrypted, str) or not encrypted: + return None, "encryptedDeliveryContext is required" + channel = _normalize_channel(payload.get("channel")) + if channel is None: + return None, f"unsupported channel '{payload.get('channel')}'" + mode = _normalize_mode(payload.get("mode")) + if mode is None: + return None, f"unsupported mode '{payload.get('mode')}'" + return { + "type": payload.get("type"), + "tenant_id": payload.get("tenantId"), + "correlation_id": payload.get("correlationId"), + "channel": channel, + "mode": mode, + "ttl_seconds": payload.get("ttlSeconds"), + "encrypted_delivery_context": encrypted, + }, None + + +def read_kid(compact_jwe): + """Reads the `kid` from the JWE protected (first) segment without decrypting.""" + header_segment = compact_jwe.split(".")[0] + header_segment += "=" * (-len(header_segment) % 4) + header = json.loads(base64.urlsafe_b64decode(header_segment)) + return header.get("kid") + + +def make_key_provider(env, secrets): + """Returns a key_provider(kid) -> PEM. Uses an inline PEM (CYOT_JWE_PRIVATE_KEY_PEM, local/dev) or a + Key Vault secret (name = JWE_PRIVATE_KEY_SECRET, else the `kid`).""" + def key_provider(kid): + pem = env.get("CYOT_JWE_PRIVATE_KEY_PEM") + if pem: + return pem + secret_name = env.get("JWE_PRIVATE_KEY_SECRET") or kid + if not secret_name: + return "" + return secrets.resolve(secret_name) + + return key_provider + + +# Reject oversized or structurally invalid JWEs before base64-decoding or allocating buffers. +MAX_JWE_LENGTH = 16384 + + +def _assert_well_formed_jwe(compact_jwe): + """Contract: exactly five non-empty compact segments; alg/enc/IV/tag are enforced by the decrypt.""" + if not isinstance(compact_jwe, str) or not compact_jwe: + raise ValueError("malformed JWE") + if len(compact_jwe) > MAX_JWE_LENGTH: + raise ValueError("delivery context exceeds size limit") + segments = compact_jwe.split(".") + if len(segments) != 5 or not all(segments): + raise ValueError("malformed JWE: expected five non-empty segments") + + +def decrypt_delivery_context(compact_jwe, key_provider): + """key_provider(kid) -> PEM string. Returns the decrypted CyotDeliveryContext dict.""" + _assert_well_formed_jwe(compact_jwe) + kid = read_kid(compact_jwe) + pem = key_provider(kid) + if not pem: + raise ValueError("private key unavailable") + key = jwk.JWK.from_pem(pem.encode("utf-8") if isinstance(pem, str) else pem) + # Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). + token = jwe_module.JWE(algs=["RSA-OAEP-256", "A256GCM"]) + token.deserialize(compact_jwe, key=key) + return json.loads(token.payload.decode("utf-8")) + + +def context_to_dispatch(context, envelope, message_id): + """Maps the decrypted context + envelope onto the engine's dispatch shape. The message is + pre-rendered (already contains the passcode), so the fields mirror CyotDeliveryContext.""" + return DispatchRequest( + destination=context.get("phoneNumber"), + message=context.get("message"), + channel=CHANNEL_BY_CODE[envelope["channel"]], + message_id=message_id, + correlation_id=envelope["correlation_id"], + locale=context.get("locale"), + ) diff --git a/python/src/dispatch.py b/python/src/dispatch.py new file mode 100644 index 0000000..269897b --- /dev/null +++ b/python/src/dispatch.py @@ -0,0 +1,121 @@ +"""Core engine: resolve provider -> credential (Key Vault) -> endpoint -> adapter +builds the request -> send with a timeout -> map status to outcome + HTTP status. Fail-closed.""" +import os + +import requests + +from .models import CONTINUE +from .outcome import DEFAULT_CHANNELS, resolve_outcome, to_http_status + +DEFAULT_TIMEOUT_MS = 1500 + + +class DispatchEngine: + def __init__(self, registry, secrets, env=None): + self.registry = registry + self.secrets = secrets + self.env = env if env is not None else os.environ + + def dispatch(self, dispatch, request_provider, shutter, request_id, log): + adapter = self.registry.resolve(request_provider) + if adapter is None: + log.warning("[DISPATCH_ERROR] requestId=%s unknown provider=%s", request_id, request_provider or "n/a") + return 400, {"status": "error", "reason": "unknown provider", "requestId": request_id} + + manifest = adapter.manifest + provider_id = manifest["id"] + channel = (dispatch.channel or "sms").lower() + + if channel not in DEFAULT_CHANNELS: + return 400, {"status": "error", "provider": provider_id, "reason": f"channel '{channel}' not supported", "requestId": request_id} + + # Credential (fail closed 502 if missing) — this is our credential, not the caller's token. + credential = None + try: + credential = self._resolve_credential(manifest["auth"]) + except Exception as error: + log.error("[DISPATCH_ERROR] requestId=%s provider=%s credential error=%s", request_id, provider_id, error) + + auth = manifest["auth"] + identity_required = ( + credential is not None + and credential["mode"] == "apiKey" + and bool(auth.get("identity_key_vault_secret_name")) + ) + credential_unavailable = ( + credential is None + or (credential["mode"] == "oauth2" and not credential.get("token")) + or (credential["mode"] == "apiKey" and not credential.get("secret")) + or (identity_required and not credential.get("identity")) + ) + if credential_unavailable: + return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) + + endpoint = self._resolve_endpoint(manifest) + if not endpoint: + return 502, self._fail_body(provider_id, channel, "provider endpoint not configured", dispatch, request_id) + + provider_request = adapter.build_request(channel, endpoint, dispatch, credential, self.env) + log.info("[DISPATCH] requestId=%s provider=%s channel=%s shutter=%s", request_id, provider_id, channel, bool(shutter)) + + if shutter: + return 200, {"status": "accepted", "shutterProcessed": True, "provider": provider_id, "channel": channel, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} + + timeout_ms = int(self.env.get("ENDPOINT_TIMEOUT_MS") or DEFAULT_TIMEOUT_MS) + try: + response = requests.request( + provider_request["method"], + provider_request["url"], + headers=provider_request["headers"], + data=provider_request["body"], + timeout=timeout_ms / 1000, + ) + except requests.exceptions.Timeout: + log.warning("[DISPATCH_TIMEOUT] requestId=%s provider=%s", request_id, provider_id) + return 504, self._fail_body(provider_id, channel, f"endpoint timeout after {timeout_ms}ms", dispatch, request_id) + except requests.exceptions.RequestException as error: + log.error("[DISPATCH_ERROR] requestId=%s provider=%s reason=%s", request_id, provider_id, error) + return 502, self._fail_body(provider_id, channel, str(error), dispatch, request_id) + + try: + body_json = response.json() + except ValueError: + body_json = {} + + ok = 200 <= response.status_code < 300 + parsed = adapter.parse_response(response.status_code, ok, body_json) + outcome = resolve_outcome(manifest, parsed) + http_status = to_http_status(outcome, parsed.get("provider_http_status") or response.status_code) + + log.info("[DISPATCH_RESULT] requestId=%s provider=%s channel=%s outcome=%s httpStatus=%s", request_id, provider_id, channel, outcome, http_status) + + return http_status, { + "status": "accepted" if outcome == CONTINUE else "failed", + "outcome": outcome, + "provider": provider_id, + "channel": channel, + "messageId": dispatch.message_id, + "correlationId": dispatch.correlation_id, + "providerMessageId": parsed.get("provider_message_id"), + "providerStatus": parsed.get("provider_status_name") or parsed.get("provider_status_code"), + "providerStatusDescription": parsed.get("provider_status_description"), + "requestId": request_id, + } + + def _resolve_credential(self, auth): + if auth.get("mode") == "oauth2": + return {"mode": "oauth2", "token": None} # not wired -> fails closed + secret = self.secrets.resolve(auth.get("key_vault_secret_name")) + identity = self.secrets.resolve(auth.get("identity_key_vault_secret_name")) if auth.get("identity_key_vault_secret_name") else "" + return {"mode": "apiKey", "secret": secret, "identity": identity} + + def _resolve_endpoint(self, manifest): + id_upper = manifest["id"].upper() + use_eudb = (self.env.get("EUDB") or "").lower() == "true" + eudb_endpoint = self.env.get(f"{id_upper}_ENDPOINT_EUDB") + if use_eudb and eudb_endpoint: + return eudb_endpoint + return self.env.get(f"{id_upper}_ENDPOINT") + + def _fail_body(self, provider, channel, reason, dispatch, request_id): + return {"status": "failed", "outcome": "Fail", "provider": provider, "channel": channel, "reason": reason, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} diff --git a/python/src/models.py b/python/src/models.py new file mode 100644 index 0000000..b352185 --- /dev/null +++ b/python/src/models.py @@ -0,0 +1,18 @@ +"""Shared contract types for the CYOT OTP engine (see /docs/CONTRACT.md).""" +from dataclasses import dataclass + +# Outcomes (mirrors the other languages). +CONTINUE = "Continue" +FAIL = "Fail" +BLOCK = "Block" +STEP_UP = "StepUp" + + +@dataclass +class DispatchRequest: + destination: str + message: str | None + channel: str + message_id: str + correlation_id: str | None + locale: str | None diff --git a/python/src/outcome.py b/python/src/outcome.py new file mode 100644 index 0000000..4ef1459 --- /dev/null +++ b/python/src/outcome.py @@ -0,0 +1,30 @@ +"""Maps a provider status to an outcome, then to an HTTP status. Fail-closed.""" +from .models import CONTINUE, FAIL, BLOCK, STEP_UP + +DEFAULT_CHANNELS = ["sms", "voice"] + + +def resolve_outcome(manifest, parsed): + mapping = manifest["response_mapping"] + key = parsed.get("provider_status_name") or parsed.get("provider_status_code") + if key: + return mapping.get(key) or mapping.get("default", FAIL) + return CONTINUE if parsed.get("success") else mapping.get("default", FAIL) + + +def to_http_status(outcome, provider_http_status): + """Continue 200, Block 403, StepUp 409; a Fail surfaces the provider's failure class.""" + if outcome == CONTINUE: + return 200 + if outcome == BLOCK: + return 403 + if outcome == STEP_UP: + return 409 + if outcome == FAIL: + if provider_http_status == 429: + return 429 + if provider_http_status in (401, 403): + return 401 + if 400 <= provider_http_status < 500: + return 400 + return 502 diff --git a/python/src/providers/__init__.py b/python/src/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/src/providers/infobip.py b/python/src/providers/infobip.py new file mode 100644 index 0000000..2cc5cdd --- /dev/null +++ b/python/src/providers/infobip.py @@ -0,0 +1,55 @@ +"""Infobip: SMS via /sms/3/messages, voice via /tts/3/advanced. Auth: App API key.""" +import json + + +class InfobipProvider: + manifest = { + "id": "infobip", + "auth": {"mode": "apiKey", "key_vault_secret_name": "infobip-api-key"}, + "response_mapping": { + "ACCEPTED": "Continue", + "PENDING": "Continue", + "DELIVERED": "Continue", + "REJECTED": "Fail", + "EXPIRED": "Fail", + "UNDELIVERABLE": "Fail", + "default": "Fail", + }, + } + + def build_request(self, channel, endpoint, dispatch, credential, env): + sender_id = env.get("INFOBIP_SENDER_ID") or "Verify" + authorization = f"Bearer {credential['token']}" if credential["mode"] == "oauth2" else f"App {credential['secret']}" + headers = {"Authorization": authorization, "Content-Type": "application/json", "Accept": "application/json"} + message_id = dispatch.correlation_id or dispatch.message_id + + if channel == "voice": + body = {"messages": [{ + "from": env.get("INFOBIP_VOICE_FROM") or sender_id, + "destinations": [{"to": dispatch.destination, "messageId": message_id}], + "text": dispatch.message, + "language": dispatch.locale or "en", + "voice": {"name": "Joanna", "gender": "female"}, + }]} + return {"url": f"{endpoint}/tts/3/advanced", "method": "POST", "headers": headers, "body": json.dumps(body)} + + body = {"messages": [{ + "sender": sender_id, + "destinations": [{"to": dispatch.destination, "messageId": message_id}], + "content": {"text": dispatch.message}, + }]} + return {"url": f"{endpoint}/sms/3/messages", "method": "POST", "headers": headers, "body": json.dumps(body)} + + def parse_response(self, http_status, ok, json_body): + messages = json_body.get("messages") if isinstance(json_body, dict) else None + first_message = messages[0] if messages else {} + status = first_message.get("status") or {} + status_name = (status.get("groupName") or status.get("name") or "").upper() or None + return { + "success": ok, + "provider_http_status": http_status, + "provider_message_id": first_message.get("messageId"), + "provider_status_name": status_name, + "provider_status_code": None, + "provider_status_description": status.get("description"), + } diff --git a/python/src/providers/sinch.py b/python/src/providers/sinch.py new file mode 100644 index 0000000..e76bed1 --- /dev/null +++ b/python/src/providers/sinch.py @@ -0,0 +1,51 @@ +"""Sinch: SMS via XMS Batches (POST /xms/v1/{plan}/batches, Bearer). +Voice via the Calling TTS callout API.""" +import json + + +class SinchProvider: + manifest = { + "id": "sinch", + "auth": {"mode": "apiKey", "key_vault_secret_name": "sinch-api-token"}, + "response_mapping": { + "Dispatched": "Continue", "Delivered": "Continue", "Queued": "Continue", + "Failed": "Fail", "Rejected": "Fail", "default": "Fail", + }, + } + + def build_request(self, channel, endpoint, dispatch, credential, env): + bearer = credential["token"] if credential["mode"] == "oauth2" else credential["secret"] + headers = {"Authorization": f"Bearer {bearer}", "Content-Type": "application/json", "Accept": "application/json"} + reference = dispatch.correlation_id or dispatch.message_id + + if channel == "voice": + voice_base = env.get("SINCH_VOICE_ENDPOINT") or "https://calling.api.sinch.com" + body = {"method": "ttsCallout", "ttsCallout": { + "destination": {"type": "number", "endpoint": dispatch.destination}, + "text": dispatch.message, + "locale": dispatch.locale or "en-US", + "custom": reference, + }} + return {"url": f"{voice_base}/calling/v1/callouts", "method": "POST", "headers": headers, "body": json.dumps(body)} + + service_plan_id = env.get("SINCH_SERVICE_PLAN_ID") or "" + body = { + "from": env.get("SINCH_SENDER_ID") or "Verify", + "to": [dispatch.destination], + "body": dispatch.message, + "client_reference": reference, + } + return {"url": f"{endpoint}/xms/v1/{service_plan_id}/batches", "method": "POST", "headers": headers, "body": json.dumps(body)} + + def parse_response(self, http_status, ok, json_body): + identifier = None + if isinstance(json_body, dict): + identifier = json_body.get("id") or json_body.get("callId") + return { + "success": ok, + "provider_http_status": http_status, + "provider_message_id": str(identifier) if identifier is not None else None, + "provider_status_name": "Dispatched" if ok else None, + "provider_status_code": None, + "provider_status_description": json_body.get("text") if isinstance(json_body, dict) else None, + } diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py new file mode 100644 index 0000000..ea6853d --- /dev/null +++ b/python/src/providers/soprano.py @@ -0,0 +1,71 @@ +"""Soprano Connect (MEMS): POST {base}/messages/{sms|voice}. +Auth: X-MEMS-API-ID + X-MEMS-API-Key.""" +import json + + +class SopranoProvider: + manifest = { + "id": "soprano", + "auth": { + "mode": "apiKey", + "key_vault_secret_name": "soprano-api-key", + "identity_key_vault_secret_name": "soprano-api-id", + }, + "response_mapping": { + "ENROUTE": "Continue", "ACCEPTED": "Continue", "SUBMITTED": "Continue", + "SENT": "Continue", "DELIVERED": "Continue", "QUEUED": "Continue", + "FAILED": "Fail", "REJECTED": "Fail", "BLOCKED": "Block", "default": "Fail", + }, + } + + def build_request(self, channel, endpoint, dispatch, credential, env): + message_type = "voice" if channel == "voice" else "sms" + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if credential["mode"] == "oauth2": + headers["Authorization"] = f"Bearer {credential['token']}" + else: + headers["X-MEMS-API-ID"] = credential.get("identity") or "" + headers["X-MEMS-API-Key"] = credential.get("secret") or "" + + client_reference = dispatch.correlation_id or dispatch.message_id + body = {"messageType": message_type, "destination": dispatch.destination, "clientReference": client_reference} + + # Sender: a provisioned source endpoint is what Soprano accepts; free-text source is a fallback. + source_id = env.get("SOPRANO_SOURCE_ID") + if source_id: + source_type = int(env.get("SOPRANO_SOURCE_TYPE") or 1) + body["endpoints"] = [{"type": source_type, "id": int(source_id)}] + elif env.get("SOPRANO_SENDER_ID"): + body["source"] = env.get("SOPRANO_SENDER_ID") + + if message_type == "voice": + locale = dispatch.locale or "" + voice_language = env.get("SOPRANO_VOICE_LANGUAGE") or (locale if "-" in locale else "en-US") + body["voice"] = {"text2voice": { + "beforePasswordText": dispatch.message or "", + "password": "", + "afterPasswordText": "", + "language": voice_language, + "gender": int(env.get("SOPRANO_VOICE_GENDER") or 1), + "loop": 1, + }} + else: + body["text"] = dispatch.message + + return {"url": f"{endpoint}/messages/{message_type}", "method": "POST", "headers": headers, "body": json.dumps(body)} + + def parse_response(self, http_status, ok, json_body): + payload = json_body[0] if isinstance(json_body, list) and json_body else json_body + payload = payload if isinstance(payload, dict) else {} + identifier = payload.get("id") + identifier = str(identifier) if identifier is not None else payload.get("messageId") + status = payload.get("status") or payload.get("state") + status = status.upper() if status else ("SUBMITTED" if ok else None) + return { + "success": ok, + "provider_http_status": http_status, + "provider_message_id": identifier, + "provider_status_name": status, + "provider_status_code": None, + "provider_status_description": payload.get("errorDescription") or payload.get("statusText") or payload.get("description"), + } diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py new file mode 100644 index 0000000..2fdb01a --- /dev/null +++ b/python/src/providers/telesign.py @@ -0,0 +1,63 @@ +"""Telesign: SMS via /v1/messaging, voice via /v1/voice (form-urlencoded). +Auth: HTTP Basic (customer_id:api_key).""" +import base64 +import urllib.parse + + +class TelesignProvider: + manifest = { + "id": "telesign", + "auth": { + "mode": "apiKey", + "key_vault_secret_name": "telesign-api-key", + "identity_key_vault_secret_name": "telesign-customer-id", + }, + "response_mapping": { + "200": "Continue", "203": "Continue", "290": "Continue", "291": "Continue", "292": "Continue", + "100": "Continue", "101": "Continue", "102": "Continue", "103": "Continue", + "default": "Fail", + }, + } + + def build_request(self, channel, endpoint, dispatch, credential, env): + if credential["mode"] == "oauth2": + authorization = f"Bearer {credential['token']}" + else: + raw = f"{credential['identity']}:{credential['secret']}".encode() + authorization = "Basic " + base64.b64encode(raw).decode() + + external_id = dispatch.correlation_id or dispatch.message_id + if channel == "voice": + path = "/v1/voice" + form = { + "phone_number": dispatch.destination, + "message": dispatch.message or "", + "message_type": "OTP", + "voice": env.get("TELESIGN_VOICE") or "f-en-US", + "external_id": external_id, + } + else: + path = "/v1/messaging" + form = { + "phone_number": dispatch.destination, + "message": dispatch.message or "", + "sender_id": env.get("TELESIGN_SENDER_ID") or "", + "message_type": "OTP", + "external_id": external_id, + "is_primary": "true", + } + + headers = {"Authorization": authorization, "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"} + return {"url": f"{endpoint}{path}", "method": "POST", "headers": headers, "body": urllib.parse.urlencode(form)} + + def parse_response(self, http_status, ok, json_body): + status = json_body.get("status") or {} if isinstance(json_body, dict) else {} + code = status.get("code") + return { + "success": ok, + "provider_http_status": http_status, + "provider_message_id": json_body.get("reference_id") if isinstance(json_body, dict) else None, + "provider_status_name": None, + "provider_status_code": str(code) if code is not None else None, + "provider_status_description": status.get("description"), + } diff --git a/python/src/registry.py b/python/src/registry.py new file mode 100644 index 0000000..e83b8c1 --- /dev/null +++ b/python/src/registry.py @@ -0,0 +1,16 @@ +"""Registry of provider adapters + resolution of the active provider.""" +import os + + +class ProviderRegistry: + def __init__(self, adapters): + self._by_id = {adapter.manifest["id"].lower(): adapter for adapter in adapters} + + def get(self, provider_id): + if not provider_id: + return None + return self._by_id.get(provider_id.lower()) + + def resolve(self, request_provider): + provider_id = request_provider or os.environ.get("DEFAULT_PROVIDER") + return self.get(provider_id) diff --git a/python/src/secrets.py b/python/src/secrets.py new file mode 100644 index 0000000..9bf50f5 --- /dev/null +++ b/python/src/secrets.py @@ -0,0 +1,42 @@ +"""Resolves Key Vault secret names to values via the Function's managed identity +(user-assigned when AZURE_CLIENT_ID is set, else system-assigned), cached briefly.""" +import os +import time + +from azure.identity import ManagedIdentityCredential +from azure.keyvault.secrets import SecretClient + +CACHE_TTL_SECONDS = 5 * 60 + + +class SecretResolver: + def __init__(self): + self._client = None + self._cache = {} + + def _get_client(self): + if self._client is None: + vault_url = os.environ.get("KEY_VAULT_URL") + if not vault_url: + return None + client_id = os.environ.get("AZURE_CLIENT_ID") + credential = ( + ManagedIdentityCredential(client_id=client_id) + if client_id + else ManagedIdentityCredential() + ) + self._client = SecretClient(vault_url=vault_url, credential=credential) + return self._client + + def resolve(self, secret_name): + if not secret_name: + return "" + cached = self._cache.get(secret_name) + if cached and cached[1] > time.time(): + return cached[0] + client = self._get_client() + if client is None: + raise RuntimeError("KEY_VAULT_URL not set") + value = client.get_secret(secret_name).value or "" + self._cache[secret_name] = (value, time.time() + CACHE_TTL_SECONDS) + return value diff --git a/python/src/security.py b/python/src/security.py new file mode 100644 index 0000000..ee6bace --- /dev/null +++ b/python/src/security.py @@ -0,0 +1,41 @@ +"""Validates the Entra JWT when REQUIRE_AUTH=true (aud/issuer/JWKS, RS256). +No-op pass-through otherwise.""" +import os + +import jwt +from jwt import PyJWKClient + + +def validate_token(authorization_header): + """Returns (ok, reason, caller_object_id).""" + if (os.environ.get("REQUIRE_AUTH") or "").lower() != "true": + return True, None, None + + audience = os.environ.get("EXPECTED_AUDIENCE") + tenant_id = os.environ.get("ISSUER_TENANT_ID") + if not audience or not tenant_id: + return False, "auth misconfigured", None + + if not authorization_header or not authorization_header.lower().startswith("bearer "): + return False, "missing bearer token", None + + token = authorization_header[len("bearer "):].strip() + try: + jwks_url = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys" + signing_key = PyJWKClient(jwks_url).get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=audience, + options={"verify_iss": False}, + ) + allowed_issuers = ( + f"https://login.microsoftonline.com/{tenant_id}/v2.0", + f"https://sts.windows.net/{tenant_id}/", + ) + if claims.get("iss") not in allowed_issuers: + return False, "token validation failed", None + return True, None, claims.get("oid") + except Exception: + return False, "token validation failed", None diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py new file mode 100644 index 0000000..783db2d --- /dev/null +++ b/python/tests/test_contract.py @@ -0,0 +1,58 @@ +"""Conformance tests for the pure contract logic (see /docs/CONTRACT.md §6).""" +from src.models import BLOCK, CONTINUE, FAIL, STEP_UP, DispatchRequest +from src.outcome import resolve_outcome, to_http_status +from src.providers.infobip import InfobipProvider +from src.providers.telesign import TelesignProvider +from src.providers.soprano import SopranoProvider +from src.providers.sinch import SinchProvider +from src.registry import ProviderRegistry + + +def _dispatch(channel="sms", message=None): + return DispatchRequest( + destination="+15551234567", message=message, channel=channel, + message_id="m", correlation_id="c", locale=None, + ) + + +def test_outcome_and_http_status(): + manifest = InfobipProvider.manifest + assert resolve_outcome(manifest, {"success": True, "provider_status_name": "DELIVERED"}) == CONTINUE + # Unknown status fails closed even on HTTP 200. + assert resolve_outcome(manifest, {"success": True, "provider_status_name": "WATWAT"}) == FAIL + assert to_http_status(CONTINUE, 200) == 200 + assert to_http_status(BLOCK, 200) == 403 + assert to_http_status(STEP_UP, 200) == 409 + assert to_http_status(FAIL, 429) == 429 + assert to_http_status(FAIL, 403) == 401 + assert to_http_status(FAIL, 422) == 400 + assert to_http_status(FAIL, 500) == 502 + + +def test_infobip_builds_https_sms_request(): + env = {"INFOBIP_SENDER_ID": "CYOT"} + request = InfobipProvider().build_request( + "sms", "https://api.infobip.com", + _dispatch(message="Use verification code 918273 for Microsoft authentication."), + {"mode": "apiKey", "secret": "ib"}, env, + ) + assert request["url"].startswith("https://") + assert request["url"].endswith("/sms/3/messages") + assert request["headers"]["Authorization"].startswith("App ") + assert "918273" in request["body"] + + +def test_telesign_basic_auth_and_voice_mapping(): + request = TelesignProvider().build_request( + "sms", "https://rest-api.telesign.com", _dispatch(message="code 918273"), + {"mode": "apiKey", "secret": "key", "identity": "cust"}, {}, + ) + assert request["headers"]["Authorization"].startswith("Basic ") + assert request["url"].endswith("/v1/messaging") + assert resolve_outcome(TelesignProvider.manifest, {"success": True, "provider_status_code": "100"}) == CONTINUE + + +def test_registry_resolves_by_id(): + registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) + assert registry.get("TELESIGN").manifest["id"] == "telesign" + assert registry.get("nope") is None diff --git a/python/tests/test_cyot.py b/python/tests/test_cyot.py new file mode 100644 index 0000000..c9eaf74 --- /dev/null +++ b/python/tests/test_cyot.py @@ -0,0 +1,78 @@ +"""Envelope validation + JWE decryption round-trip (see docs/CONTRACT.md §1, §6).""" +import json + +from jwcrypto import jwe, jwk + +from src.cyot import ( + context_to_dispatch, + decrypt_delivery_context, + parse_envelope, + read_kid, +) + +# Throwaway RSA key: encrypt here, decrypt via the module using the private PEM. +_KEY = jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") +_PRIVATE_PEM = _KEY.export_to_pem(private_key=True, password=None).decode("utf-8") + + +def _encrypt(context, kid="test-key"): + protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": kid} + token = jwe.JWE(json.dumps(context).encode("utf-8"), protected=json.dumps(protected)) + token.add_recipient(_KEY) + return token.serialize(compact=True) + + +def _key_provider(_kid): + return _PRIVATE_PEM + + +def _sample_context(): + return {"nonce": "nonce-1", "phoneNumber": "+14255551234", "message": "Your code is 123456", "locale": "en-US"} + + +def test_missing_encrypted_context_is_error(): + envelope, error = parse_envelope({"channel": 1, "mode": 1}) + assert envelope is None + assert "encryptedDeliveryContext" in error + + +def test_unsupported_channel_is_error(): + envelope, error = parse_envelope({"channel": 9, "mode": 1, "encryptedDeliveryContext": "x"}) + assert envelope is None + assert "channel" in error + + +def test_unsupported_mode_is_error(): + envelope, error = parse_envelope({"channel": 1, "mode": 5, "encryptedDeliveryContext": "x"}) + assert envelope is None + assert "mode" in error + + +def test_valid_envelope_parses(): + envelope, error = parse_envelope({ + "type": "microsoft.mfa.otpDeliver.v1", "tenantId": "t", "correlationId": "c", + "channel": 2, "mode": 1, "ttlSeconds": 60, "encryptedDeliveryContext": "x", + }) + assert error is None + assert envelope["channel"] == 2 + assert envelope["mode"] == 1 + + +def test_jwe_round_trips_to_delivery_context(): + compact = _encrypt(_sample_context()) + assert read_kid(compact) == "test-key" + context = decrypt_delivery_context(compact, _key_provider) + assert context["nonce"] == "nonce-1" + assert context["phoneNumber"] == "+14255551234" + assert context["message"] == "Your code is 123456" + + +def test_context_to_dispatch_maps_fields(): + envelope, _ = parse_envelope({ + "correlationId": "corr-1", "channel": 2, "mode": 1, "encryptedDeliveryContext": "x", + }) + dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") + assert dispatch.destination == "+14255551234" + assert dispatch.channel == "voice" + assert dispatch.message_id == "msg-1" + assert dispatch.correlation_id == "corr-1" diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py new file mode 100644 index 0000000..65554c1 --- /dev/null +++ b/python/tests/test_engine.py @@ -0,0 +1,134 @@ +"""Engine-level conformance tests (CONTRACT.md §6) with mocked HTTP + Key Vault.""" +import json + +import pytest + +import src.dispatch as dispatch_module +from src.dispatch import DispatchEngine +from src.models import DispatchRequest +from src.providers.infobip import InfobipProvider +from src.providers.sinch import SinchProvider +from src.providers.soprano import SopranoProvider +from src.providers.telesign import TelesignProvider +from src.registry import ProviderRegistry + + +class FakeSecrets: + def __init__(self, values): + self._values = values + + def resolve(self, name): + return self._values.get(name, "") + + +class FakeResponse: + def __init__(self, status_code, body): + self.status_code = status_code + self._body = body + + def json(self): + return self._body + + +class CapturingLog: + def __init__(self): + self.lines = [] + + def _record(self, fmt, *args): + self.lines.append(fmt % args if args else fmt) + + info = _record + warning = _record + error = _record + + +_DEFAULT_SECRETS = { + "infobip-api-key": "ib", + "telesign-api-key": "ts", "telesign-customer-id": "cust", + "soprano-api-key": "sp", "soprano-api-id": "spid", +} +_DEFAULT_ENV = { + "INFOBIP_ENDPOINT": "https://api.infobip.com", + "TELESIGN_ENDPOINT": "https://rest-api.telesign.com", + "SOPRANO_ENDPOINT": "https://mems.example.com/cgpapi", + "SINCH_ENDPOINT": "https://sms.api.sinch.com", +} + + +def make_engine(secret_values=None, env=None): + registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) + secrets = FakeSecrets(_DEFAULT_SECRETS if secret_values is None else secret_values) + return DispatchEngine(registry, secrets, _DEFAULT_ENV if env is None else env) + + +def dispatch_request(**overrides): + base = dict( + destination="+15551234567", message="Your code is 918273", channel="sms", + message_id="m", correlation_id="c", locale=None, + ) + base.update(overrides) + return DispatchRequest(**base) + + +def _mock_send(monkeypatch, response=None, raise_error=None, capture=None): + def fake_request(method, url, headers=None, data=None, timeout=None): + if capture is not None: + capture["url"] = url + capture["data"] = data + if raise_error is not None: + raise raise_error + return response + monkeypatch.setattr(dispatch_module.requests, "request", fake_request) + + +def test_unknown_provider_400(): + status, body = make_engine().dispatch(dispatch_request(), "nope", False, "r", CapturingLog()) + assert status == 400 and body["reason"] == "unknown provider" + + +def test_missing_credential_502(): + status, body = make_engine(secret_values={}).dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) + assert status == 502 and body["reason"] == "provider credential unavailable" + + +def test_missing_endpoint_502(): + engine = make_engine(env={}) # no *_ENDPOINT set + status, body = engine.dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) + assert status == 502 and body["reason"] == "provider endpoint not configured" + + +def test_shutter_does_not_send(monkeypatch): + _mock_send(monkeypatch, raise_error=AssertionError("should not send")) + status, body = make_engine().dispatch(dispatch_request(), "infobip", True, "r", CapturingLog()) + assert status == 200 and body["shutterProcessed"] is True + + +def test_success_renders_code_and_keeps_privacy(monkeypatch): + capture = {} + _mock_send(monkeypatch, response=FakeResponse(200, {"messages": [{"status": {"name": "DELIVERED"}, "messageId": "x"}]}), capture=capture) + log = CapturingLog() + status, body = make_engine().dispatch(dispatch_request(), "infobip", False, "r", log) + + assert status == 200 and body["status"] == "accepted" + assert "918273" in capture["data"] # the message (with the code) IS sent to the provider (that's the delivery) + serialized = json.dumps(body) + assert "918273" not in serialized and "5551234567" not in serialized # never in the response body + assert all("918273" not in line and "5551234567" not in line for line in log.lines) # never logged + + +def test_unknown_status_fails_closed(monkeypatch): + _mock_send(monkeypatch, response=FakeResponse(200, {"messages": [{"status": {"name": "WATWAT"}}]})) + status, body = make_engine().dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) + assert body["outcome"] == "Fail" and body["status"] == "failed" + + +def test_timeout_maps_to_504(monkeypatch): + _mock_send(monkeypatch, raise_error=dispatch_module.requests.exceptions.Timeout()) + status, _ = make_engine().dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) + assert status == 504 + + +def test_network_error_maps_to_502(monkeypatch): + _mock_send(monkeypatch, raise_error=dispatch_module.requests.exceptions.ConnectionError()) + status, _ = make_engine().dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) + assert status == 502 diff --git a/scripts/provision-jwe-key.ps1 b/scripts/provision-jwe-key.ps1 new file mode 100644 index 0000000..cc97dbe --- /dev/null +++ b/scripts/provision-jwe-key.ps1 @@ -0,0 +1,67 @@ +<# +.SYNOPSIS + Generates a test RSA keypair ("test certificate") for CYOT JWE decryption and stores the private + key in Key Vault. The public key is what SAS uses to encrypt encryptedDeliveryContext; the private + key is what the Function decrypts with (resolved from Key Vault by the JOSE `kid`). + +.DESCRIPTION + RSA-OAEP-256 wraps the AES-256-GCM content key, so SAS needs the PUBLIC key (the cert) and the + endpoint needs the PRIVATE key. This script: + 1. Generates an RSA-2048 keypair. + 2. Writes both PEMs to scripts/.keys/ (gitignored — private keys never get committed). + 3. Uploads the private key PEM to Key Vault as a secret named after the `kid`. + Share the printed public key with SAS. Nothing here is a customer secret — the nonce is generated + per-request by SAS and lives inside the encrypted payload; this cert is only the lock. + +.EXAMPLE + ./scripts/provision-jwe-key.ps1 + ./scripts/provision-jwe-key.ps1 -VaultName cyot-poc-kv -Kid cyot-poc-jwe-1 + ./scripts/provision-jwe-key.ps1 -SkipUpload # local keys only, no Key Vault +#> +[CmdletBinding()] +param( + [string]$VaultName = "cyot-poc-kv", + [string]$Kid = "cyot-poc-jwe-1", + # Secret name defaults to the kid so the Function resolves it automatically (no JWE_PRIVATE_KEY_SECRET needed). + [string]$SecretName = $null, + [string]$OutDir = "$PSScriptRoot/.keys", + [switch]$SkipUpload +) + +$ErrorActionPreference = "Stop" +if (-not $SecretName) { $SecretName = $Kid } + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +Write-Host "Generating RSA-2048 keypair (test certificate)..." +$rsa = [System.Security.Cryptography.RSA]::Create(2048) +try { + $privatePem = $rsa.ExportPkcs8PrivateKeyPem() + $publicPem = $rsa.ExportSubjectPublicKeyInfoPem() +} +finally { + $rsa.Dispose() +} + +$privatePath = Join-Path $OutDir "cyot-jwe-private.pem" +$publicPath = Join-Path $OutDir "cyot-jwe-public.pem" +Set-Content -Path $privatePath -Value $privatePem -NoNewline +Set-Content -Path $publicPath -Value $publicPem -NoNewline + +Write-Host " private -> $privatePath (kept local, gitignored)" +Write-Host " public -> $publicPath (share with SAS as the encryption cert)" + +if ($SkipUpload) { + Write-Host "`nSkipped Key Vault upload (-SkipUpload). To run the endpoint locally, set:" + Write-Host " `$env:CYOT_JWE_PRIVATE_KEY_PEM = Get-Content $privatePath -Raw" +} +else { + Write-Host "`nUploading private key to Key Vault '$VaultName' secret '$SecretName'..." + az keyvault secret set --vault-name $VaultName --name $SecretName --file $privatePath --tags "kid=$Kid" --output none + if ($LASTEXITCODE -ne 0) { throw "Key Vault upload failed (need Key Vault Secrets Officer on $VaultName)." } + Write-Host " done. The Function resolves it by kid='$Kid' (secret name matches)." + Write-Host " If the secret name differs from the kid, set app setting JWE_PRIVATE_KEY_SECRET=$SecretName." +} + +Write-Host "`n----- PUBLIC KEY (kid=$Kid) — give this to SAS -----" +Write-Host $publicPem diff --git a/scripts/test-nonce-echo.mjs b/scripts/test-nonce-echo.mjs new file mode 100644 index 0000000..7d2f73c --- /dev/null +++ b/scripts/test-nonce-echo.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// End-to-end nonce-echo check for the CYOT endpoint. +// +// Generates a random 16-byte nonce, builds a CyotDeliveryContext, encrypts it as a JWE +// (RSA-OAEP-256 + A256GCM) with the test certificate's PUBLIC key, wraps it in the SAS envelope, then +// verifies the endpoint echoes the nonce (proof of decryption — the same check SAS does with +// FixedTimeEquals). Evaluation mode (mode=2) by default, so nothing is actually delivered. +// +// Usage: +// node scripts/test-nonce-echo.mjs # in-process self-test (uses the real decrypt code) +// node scripts/test-nonce-echo.mjs --url=http://localhost:7071/api/SendOtp +// node scripts/test-nonce-echo.mjs --url=https://.azurewebsites.net/api/SendOtp --token= +// node scripts/test-nonce-echo.mjs --publicKey=scripts/.keys/cyot-jwe-public.pem --kid=cyot-poc-jwe-1 +// +// With no --url it decrypts locally via ../javascript/src/functions/cyot.js and echoes — zero setup, +// always runnable. With --url it POSTs to a live endpoint (which must hold the matching private key). + +import { createRequire } from 'node:module'; +import { readFileSync, existsSync } from 'node:fs'; +import { randomBytes, timingSafeEqual, generateKeyPairSync } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const jose = require(join(here, '../javascript/node_modules/jose')); +const cyot = require(join(here, '../javascript/src/functions/cyot.js')); + +const args = Object.fromEntries(process.argv.slice(2).map((a) => { + const raw = a.replace(/^--/, ''); + const eq = raw.indexOf('='); + return eq === -1 ? [raw, true] : [raw.slice(0, eq), raw.slice(eq + 1)]; +})); + +const kid = args.kid || 'cyot-poc-jwe-1'; +const mode = Number(args.mode || 2); // 2 = Evaluation (no delivery) +const token = args.token || process.env.CYOT_TOKEN || null; + +// Key material: use the provisioned test cert if present, else generate an ephemeral keypair so the +// self-test runs with zero setup. +let publicPem; +let privatePem; +const defaultPublic = join(here, '.keys/cyot-jwe-public.pem'); +const publicKeyPath = args.publicKey ? resolve(String(args.publicKey)) : defaultPublic; +if (existsSync(publicKeyPath)) { + publicPem = readFileSync(publicKeyPath, 'utf8'); + const privatePath = join(dirname(publicKeyPath), 'cyot-jwe-private.pem'); + if (existsSync(privatePath)) privatePem = readFileSync(privatePath, 'utf8'); + console.log(`Using test certificate: ${publicKeyPath} (kid=${kid})`); +} else { + const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }); + publicPem = pair.publicKey.export({ type: 'spki', format: 'pem' }); + privatePem = pair.privateKey.export({ type: 'pkcs8', format: 'pem' }); + console.log(`No test cert found; generated an ephemeral keypair for the self-test (kid=${kid})`); +} + +const nonce = randomBytes(16).toString('base64url'); +const context = { + nonce, + phoneNumber: args.phone || '+14255551234', + locale: 'en-US', + message: args.message || 'Your code is 1 2 3 4 5 6', +}; + +const publicKey = await jose.importSPKI(publicPem, 'RSA-OAEP-256'); +const encryptedDeliveryContext = await new jose.CompactEncrypt(Buffer.from(JSON.stringify(context))) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', kid }) + .encrypt(publicKey); + +const envelope = { + type: 'microsoft.mfa.otpDeliver.v1', + tenantId: '00000000-0000-0000-0000-000000000000', + correlationId: 'nonce-echo-' + Date.now(), + channel: 1, + mode, + ttlSeconds: 60, + encryptedDeliveryContext, +}; + +let status; +let echoedNonce = ''; +let responseBody; + +if (args.url) { + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'Microsoft-AzureMFA-SAS-CYOT/1.0', + 'x-ms-correlation-id': envelope.correlationId, + 'x-ms-client-request-id': 'attempt-' + Date.now(), + }; + if (token) headers.Authorization = `Bearer ${token}`; + console.log(`POST ${args.url} (mode=${mode}, nonce=${nonce})`); + const res = await fetch(String(args.url), { method: 'POST', headers, body: JSON.stringify(envelope) }); + status = res.status; + responseBody = await res.text(); + try { echoedNonce = JSON.parse(responseBody).nonce || ''; } catch { echoedNonce = ''; } +} else { + // In-process: decrypt with the real production code, then echo like the endpoint does. + if (!privatePem) { + console.error('FAIL: no private key available for the self-test (provide --url or the .keys pair).'); + process.exit(1); + } + console.log(`Self-test (in-process decrypt via cyot.js) (mode=${mode}, nonce=${nonce})`); + const parsed = cyot.parseEnvelope(envelope); + if (parsed.error) { console.error(`FAIL: envelope rejected: ${parsed.error}`); process.exit(1); } + const decrypted = await cyot.decryptDeliveryContext(envelope.encryptedDeliveryContext, { keyProvider: () => privatePem }); + echoedNonce = decrypted.nonce; // the endpoint echoes this back verbatim + status = 200; + responseBody = JSON.stringify({ nonce: echoedNonce, correlationId: envelope.correlationId, providerStatus: 'accepted' }); +} + +console.log(`-> ${status} ${responseBody}`); + +const expected = Buffer.from(nonce); +const actual = Buffer.from(echoedNonce); +const ok = status >= 200 && status < 300 && expected.length === actual.length && timingSafeEqual(expected, actual); +console.log(ok + ? 'PASS: nonce echoed and matches — decryption proven.' + : 'FAIL: nonce mismatch / non-2xx — SAS would fall back to native CAPP delivery.'); +process.exit(ok ? 0 : 1); From 963933092f7523a4d3b9f46cdf9e68880b84615a Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Tue, 18 Aug 2026 11:53:26 -0700 Subject: [PATCH 02/17] Remove helper scripts from the sample Drop scripts/ (JWE key provisioning + nonce-echo test client) and the scripts-specific .gitignore entry. --- .gitignore | 3 +- scripts/provision-jwe-key.ps1 | 67 ------------------- scripts/test-nonce-echo.mjs | 120 ---------------------------------- 3 files changed, 1 insertion(+), 189 deletions(-) delete mode 100644 scripts/provision-jwe-key.ps1 delete mode 100644 scripts/test-nonce-echo.mjs diff --git a/.gitignore b/.gitignore index f282ea8..9d58729 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,7 @@ venv/ # Azure Functions local settings — may contain secrets; never commit. local.settings.json -# Local JWE test keys / certificates — never commit private keys. -scripts/.keys/ +# Keys / certificates — never commit private keys. *.pem *.pfx diff --git a/scripts/provision-jwe-key.ps1 b/scripts/provision-jwe-key.ps1 deleted file mode 100644 index cc97dbe..0000000 --- a/scripts/provision-jwe-key.ps1 +++ /dev/null @@ -1,67 +0,0 @@ -<# -.SYNOPSIS - Generates a test RSA keypair ("test certificate") for CYOT JWE decryption and stores the private - key in Key Vault. The public key is what SAS uses to encrypt encryptedDeliveryContext; the private - key is what the Function decrypts with (resolved from Key Vault by the JOSE `kid`). - -.DESCRIPTION - RSA-OAEP-256 wraps the AES-256-GCM content key, so SAS needs the PUBLIC key (the cert) and the - endpoint needs the PRIVATE key. This script: - 1. Generates an RSA-2048 keypair. - 2. Writes both PEMs to scripts/.keys/ (gitignored — private keys never get committed). - 3. Uploads the private key PEM to Key Vault as a secret named after the `kid`. - Share the printed public key with SAS. Nothing here is a customer secret — the nonce is generated - per-request by SAS and lives inside the encrypted payload; this cert is only the lock. - -.EXAMPLE - ./scripts/provision-jwe-key.ps1 - ./scripts/provision-jwe-key.ps1 -VaultName cyot-poc-kv -Kid cyot-poc-jwe-1 - ./scripts/provision-jwe-key.ps1 -SkipUpload # local keys only, no Key Vault -#> -[CmdletBinding()] -param( - [string]$VaultName = "cyot-poc-kv", - [string]$Kid = "cyot-poc-jwe-1", - # Secret name defaults to the kid so the Function resolves it automatically (no JWE_PRIVATE_KEY_SECRET needed). - [string]$SecretName = $null, - [string]$OutDir = "$PSScriptRoot/.keys", - [switch]$SkipUpload -) - -$ErrorActionPreference = "Stop" -if (-not $SecretName) { $SecretName = $Kid } - -New-Item -ItemType Directory -Force -Path $OutDir | Out-Null - -Write-Host "Generating RSA-2048 keypair (test certificate)..." -$rsa = [System.Security.Cryptography.RSA]::Create(2048) -try { - $privatePem = $rsa.ExportPkcs8PrivateKeyPem() - $publicPem = $rsa.ExportSubjectPublicKeyInfoPem() -} -finally { - $rsa.Dispose() -} - -$privatePath = Join-Path $OutDir "cyot-jwe-private.pem" -$publicPath = Join-Path $OutDir "cyot-jwe-public.pem" -Set-Content -Path $privatePath -Value $privatePem -NoNewline -Set-Content -Path $publicPath -Value $publicPem -NoNewline - -Write-Host " private -> $privatePath (kept local, gitignored)" -Write-Host " public -> $publicPath (share with SAS as the encryption cert)" - -if ($SkipUpload) { - Write-Host "`nSkipped Key Vault upload (-SkipUpload). To run the endpoint locally, set:" - Write-Host " `$env:CYOT_JWE_PRIVATE_KEY_PEM = Get-Content $privatePath -Raw" -} -else { - Write-Host "`nUploading private key to Key Vault '$VaultName' secret '$SecretName'..." - az keyvault secret set --vault-name $VaultName --name $SecretName --file $privatePath --tags "kid=$Kid" --output none - if ($LASTEXITCODE -ne 0) { throw "Key Vault upload failed (need Key Vault Secrets Officer on $VaultName)." } - Write-Host " done. The Function resolves it by kid='$Kid' (secret name matches)." - Write-Host " If the secret name differs from the kid, set app setting JWE_PRIVATE_KEY_SECRET=$SecretName." -} - -Write-Host "`n----- PUBLIC KEY (kid=$Kid) — give this to SAS -----" -Write-Host $publicPem diff --git a/scripts/test-nonce-echo.mjs b/scripts/test-nonce-echo.mjs deleted file mode 100644 index 7d2f73c..0000000 --- a/scripts/test-nonce-echo.mjs +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env node -// End-to-end nonce-echo check for the CYOT endpoint. -// -// Generates a random 16-byte nonce, builds a CyotDeliveryContext, encrypts it as a JWE -// (RSA-OAEP-256 + A256GCM) with the test certificate's PUBLIC key, wraps it in the SAS envelope, then -// verifies the endpoint echoes the nonce (proof of decryption — the same check SAS does with -// FixedTimeEquals). Evaluation mode (mode=2) by default, so nothing is actually delivered. -// -// Usage: -// node scripts/test-nonce-echo.mjs # in-process self-test (uses the real decrypt code) -// node scripts/test-nonce-echo.mjs --url=http://localhost:7071/api/SendOtp -// node scripts/test-nonce-echo.mjs --url=https://.azurewebsites.net/api/SendOtp --token= -// node scripts/test-nonce-echo.mjs --publicKey=scripts/.keys/cyot-jwe-public.pem --kid=cyot-poc-jwe-1 -// -// With no --url it decrypts locally via ../javascript/src/functions/cyot.js and echoes — zero setup, -// always runnable. With --url it POSTs to a live endpoint (which must hold the matching private key). - -import { createRequire } from 'node:module'; -import { readFileSync, existsSync } from 'node:fs'; -import { randomBytes, timingSafeEqual, generateKeyPairSync } from 'node:crypto'; -import { fileURLToPath } from 'node:url'; -import { dirname, join, resolve } from 'node:path'; - -const here = dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); -const jose = require(join(here, '../javascript/node_modules/jose')); -const cyot = require(join(here, '../javascript/src/functions/cyot.js')); - -const args = Object.fromEntries(process.argv.slice(2).map((a) => { - const raw = a.replace(/^--/, ''); - const eq = raw.indexOf('='); - return eq === -1 ? [raw, true] : [raw.slice(0, eq), raw.slice(eq + 1)]; -})); - -const kid = args.kid || 'cyot-poc-jwe-1'; -const mode = Number(args.mode || 2); // 2 = Evaluation (no delivery) -const token = args.token || process.env.CYOT_TOKEN || null; - -// Key material: use the provisioned test cert if present, else generate an ephemeral keypair so the -// self-test runs with zero setup. -let publicPem; -let privatePem; -const defaultPublic = join(here, '.keys/cyot-jwe-public.pem'); -const publicKeyPath = args.publicKey ? resolve(String(args.publicKey)) : defaultPublic; -if (existsSync(publicKeyPath)) { - publicPem = readFileSync(publicKeyPath, 'utf8'); - const privatePath = join(dirname(publicKeyPath), 'cyot-jwe-private.pem'); - if (existsSync(privatePath)) privatePem = readFileSync(privatePath, 'utf8'); - console.log(`Using test certificate: ${publicKeyPath} (kid=${kid})`); -} else { - const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }); - publicPem = pair.publicKey.export({ type: 'spki', format: 'pem' }); - privatePem = pair.privateKey.export({ type: 'pkcs8', format: 'pem' }); - console.log(`No test cert found; generated an ephemeral keypair for the self-test (kid=${kid})`); -} - -const nonce = randomBytes(16).toString('base64url'); -const context = { - nonce, - phoneNumber: args.phone || '+14255551234', - locale: 'en-US', - message: args.message || 'Your code is 1 2 3 4 5 6', -}; - -const publicKey = await jose.importSPKI(publicPem, 'RSA-OAEP-256'); -const encryptedDeliveryContext = await new jose.CompactEncrypt(Buffer.from(JSON.stringify(context))) - .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', kid }) - .encrypt(publicKey); - -const envelope = { - type: 'microsoft.mfa.otpDeliver.v1', - tenantId: '00000000-0000-0000-0000-000000000000', - correlationId: 'nonce-echo-' + Date.now(), - channel: 1, - mode, - ttlSeconds: 60, - encryptedDeliveryContext, -}; - -let status; -let echoedNonce = ''; -let responseBody; - -if (args.url) { - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'Microsoft-AzureMFA-SAS-CYOT/1.0', - 'x-ms-correlation-id': envelope.correlationId, - 'x-ms-client-request-id': 'attempt-' + Date.now(), - }; - if (token) headers.Authorization = `Bearer ${token}`; - console.log(`POST ${args.url} (mode=${mode}, nonce=${nonce})`); - const res = await fetch(String(args.url), { method: 'POST', headers, body: JSON.stringify(envelope) }); - status = res.status; - responseBody = await res.text(); - try { echoedNonce = JSON.parse(responseBody).nonce || ''; } catch { echoedNonce = ''; } -} else { - // In-process: decrypt with the real production code, then echo like the endpoint does. - if (!privatePem) { - console.error('FAIL: no private key available for the self-test (provide --url or the .keys pair).'); - process.exit(1); - } - console.log(`Self-test (in-process decrypt via cyot.js) (mode=${mode}, nonce=${nonce})`); - const parsed = cyot.parseEnvelope(envelope); - if (parsed.error) { console.error(`FAIL: envelope rejected: ${parsed.error}`); process.exit(1); } - const decrypted = await cyot.decryptDeliveryContext(envelope.encryptedDeliveryContext, { keyProvider: () => privatePem }); - echoedNonce = decrypted.nonce; // the endpoint echoes this back verbatim - status = 200; - responseBody = JSON.stringify({ nonce: echoedNonce, correlationId: envelope.correlationId, providerStatus: 'accepted' }); -} - -console.log(`-> ${status} ${responseBody}`); - -const expected = Buffer.from(nonce); -const actual = Buffer.from(echoedNonce); -const ok = status >= 200 && status < 300 && expected.length === actual.length && timingSafeEqual(expected, actual); -console.log(ok - ? 'PASS: nonce echoed and matches — decryption proven.' - : 'FAIL: nonce mismatch / non-2xx — SAS would fall back to native CAPP delivery.'); -process.exit(ok ? 0 : 1); From a33d40eb4ec878811558976668cf002a5cdf36b9 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Tue, 18 Aug 2026 14:44:23 -0700 Subject: [PATCH 03/17] Address PR review: cross-language auth/robustness fixes - CONTRACT: evaluation scenario shows 202 (matches implementations) - Sample settings: include 'python' worker runtime - JS token validation: accept both v2 and v1 (sts.windows.net) issuers, like .NET/Python - Python: cache PyJWKClient per tenant (avoid per-request JWKS fetch) - Python: fall back to default when ENDPOINT_TIMEOUT_MS is non-integer --- docs/CONTRACT.md | 2 +- docs/local.settings.sample.json | 4 ++-- javascript/src/functions/security.js | 8 ++++++-- python/src/dispatch.py | 5 ++++- python/src/security.py | 14 ++++++++++++-- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 6b4f686..74a84d5 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -155,5 +155,5 @@ Every implementation ships tests covering at least: `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. +8. `Evaluation` mode → 202 + nonce echo, nothing sent. 9. Privacy: OTP code and phone never in logs or response body. diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index cca1520..be85554 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -1,8 +1,8 @@ { - "_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.", + "_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", + "FUNCTIONS_WORKER_RUNTIME": "node | dotnet-isolated | python", "DEFAULT_PROVIDER": "", "KEY_VAULT_URL": "https://.vault.azure.net/", diff --git a/javascript/src/functions/security.js b/javascript/src/functions/security.js index edf4ee5..e6f63eb 100644 --- a/javascript/src/functions/security.js +++ b/javascript/src/functions/security.js @@ -41,10 +41,14 @@ async function validateToken(request, context, requestId) { try { const { jwtVerify } = require('jose'); - const issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`; + // Accept both the v2 (login.microsoftonline.com/.../v2.0) and v1 (sts.windows.net/.../) issuers. + const issuers = [ + `https://login.microsoftonline.com/${tenantId}/v2.0`, + `https://sts.windows.net/${tenantId}/`, + ]; await jwtVerify(bearerToken, getJwks(tenantId), { audience, - issuer, + issuer: issuers, algorithms: ['RS256'], }); return { ok: true }; diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 269897b..69ef6ab 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -61,7 +61,10 @@ def dispatch(self, dispatch, request_provider, shutter, request_id, log): if shutter: return 200, {"status": "accepted", "shutterProcessed": True, "provider": provider_id, "channel": channel, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} - timeout_ms = int(self.env.get("ENDPOINT_TIMEOUT_MS") or DEFAULT_TIMEOUT_MS) + try: + timeout_ms = int(self.env.get("ENDPOINT_TIMEOUT_MS") or DEFAULT_TIMEOUT_MS) + except (TypeError, ValueError): + timeout_ms = DEFAULT_TIMEOUT_MS try: response = requests.request( provider_request["method"], diff --git a/python/src/security.py b/python/src/security.py index ee6bace..75113de 100644 --- a/python/src/security.py +++ b/python/src/security.py @@ -5,6 +5,17 @@ import jwt from jwt import PyJWKClient +# Cache one JWKS client per issuer tenant so signing keys are fetched once, not per request. +_jwks_clients = {} + + +def _jwks_client(tenant_id): + client = _jwks_clients.get(tenant_id) + if client is None: + client = PyJWKClient(f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys") + _jwks_clients[tenant_id] = client + return client + def validate_token(authorization_header): """Returns (ok, reason, caller_object_id).""" @@ -21,8 +32,7 @@ def validate_token(authorization_header): token = authorization_header[len("bearer "):].strip() try: - jwks_url = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys" - signing_key = PyJWKClient(jwks_url).get_signing_key_from_jwt(token) + signing_key = _jwks_client(tenant_id).get_signing_key_from_jwt(token) claims = jwt.decode( token, signing_key.key, From 7beccaa98b3fa2723710ba6745689ee4bd56495c Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Wed, 19 Aug 2026 15:09:23 -0700 Subject: [PATCH 04/17] Rename customer-facing name from CYOT to External Phone Provider CYOT remains the internal code name and is unchanged in wire-level identifiers (type names, CYOT_JWE_PRIVATE_KEY_PEM, SAS User-Agent); CONTRACT.md now notes this explicitly. --- README.md | 2 +- docs/CONTRACT.md | 6 +++++- docs/ONBOARDING.md | 8 ++++---- dotnet/README.md | 4 ++-- javascript/README.md | 6 +++--- python/README.md | 4 ++-- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 38dcc12..879fe57 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# CYOT 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 diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 74a84d5..ded261d 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,8 +1,12 @@ -# CYOT Function — Language-Agnostic Contract +# 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. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index c0aae97..288332a 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -1,9 +1,9 @@ # 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). +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 diff --git a/dotnet/README.md b/dotnet/README.md index 0c1e74e..f4af120 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1,6 +1,6 @@ -# CYOT Function — C# (.NET isolated worker) +# External Phone Provider Function — C# (.NET isolated worker) -A C# implementation of the CYOT OTP-delivery Function, conforming to the shared +A C# implementation of the External Phone Provider OTP-delivery Function, conforming to the shared [contract](../docs/CONTRACT.md). Same design as the [`javascript/`](../javascript/) version: one dispatch engine + drop-in provider adapters, env-provisioned config, secrets in Key Vault. diff --git a/javascript/README.md b/javascript/README.md index 6304ea1..90043fe 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -1,4 +1,4 @@ -# CYOT — Azure Function (delivery endpoint) +# External Phone Provider — Azure Function (delivery endpoint) An Azure Function (Node.js) that receives an OTP dispatch request and forwards it to a telephony provider (**Infobip**, **Telesign**, **Sinch**, or **Soprano**). @@ -29,7 +29,7 @@ func azure functionapp publish ## Configuration -CYOT is **plug-and-play by provider**. The **shared infrastructure** — token validation, dispatch, +The endpoint is **plug-and-play by provider**. The **shared infrastructure** — token validation, dispatch, response normalization, message templating, and logging — is identical for every provider and needs no per-provider code. You **choose one provider**; the only provider-specific parts are its **adapter** (the outbound API call) and the **few settings** below. @@ -155,7 +155,7 @@ The engine handles the rest — provider resolution, Key Vault credential fetch ## Request contract -`POST /api/SendOtp` — the SAS → CYOT delivery endpoint. The cleartext body is a routing envelope; the +`POST /api/SendOtp` — the SAS → External Phone Provider delivery endpoint. The cleartext body is a routing envelope; the PII (phone + rendered message, which contains the passcode) is encrypted in a JWE. See [../docs/CONTRACT.md](../docs/CONTRACT.md) for the full contract. diff --git a/python/README.md b/python/README.md index 34f5644..d79f7ba 100644 --- a/python/README.md +++ b/python/README.md @@ -1,6 +1,6 @@ -# CYOT Function — Python (v2 model) +# External Phone Provider Function — Python (v2 model) -A Python implementation of the CYOT OTP-delivery Function, conforming to the shared +A Python implementation of the External Phone Provider OTP-delivery Function, conforming to the shared [contract](../docs/CONTRACT.md). Same design as the [`javascript/`](../javascript/) and [`dotnet/`](../dotnet/) versions: one dispatch engine + drop-in provider adapters, env-provisioned config, secrets in Key Vault. From c93c63f1991e3338e4e78f4289cd89e89b25f9ac Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 09:36:11 -0700 Subject: [PATCH 05/17] Align all three samples with the External Phone Provider Preview 1 contract Config is now EPP_* prefixed, with a shared config module on the JavaScript side. The endpoint returns 200 with {nonce, correlationId, providerStatus} and responds before dispatching to the provider, so it stays inside the SAS timeout budget. JWE handling pins RSA-OAEP-256 + A256GCM, validates the five compact segments, caps body size, and accepts the base64-wrapped PEM that Key Vault references produce. channel and mode accept both integer and lowercase-string forms; voice passcodes are digit-spaced for TTS. Python now honours EPP_EXPECTED_ISSUER and runs delivery on a daemon thread. Removes dead code: readKid, unused manifest/secrets parameters, and the unimplemented retry interval. --- README.md | 8 +- docs/CONTRACT.md | 57 ++-- docs/ONBOARDING.md | 13 +- docs/local.settings.sample.json | 29 ++- dotnet/Functions/SendOtp.cs | 243 +++++++++++++----- dotnet/Program.cs | 2 +- dotnet/README.md | 4 +- dotnet/Src/DeliveryContext.cs | 59 +++-- dotnet/Src/DispatchEngine.cs | 14 +- dotnet/Src/ProviderRegistry.cs | 2 +- dotnet/Src/Providers/InfobipProvider.cs | 4 +- dotnet/Src/Providers/SinchProvider.cs | 2 +- dotnet/Src/Providers/SopranoProvider.cs | 10 +- dotnet/Src/Providers/TelesignProvider.cs | 2 +- dotnet/Src/TokenValidator.cs | 13 +- dotnet/tests/ContractTests.cs | 2 +- dotnet/tests/EngineTests.cs | 3 +- dotnet/tests/EnvelopeTests.cs | 8 +- javascript/README.md | 62 +++-- javascript/src/functions/SendOtp.js | 233 ++++++++++++----- javascript/src/functions/config.js | 44 ++++ javascript/src/functions/cyot.js | 71 +++-- javascript/src/functions/dispatch.js | 24 +- javascript/src/functions/providers/infobip.js | 4 +- javascript/src/functions/providers/sinch.js | 2 +- javascript/src/functions/providers/soprano.js | 11 +- .../src/functions/providers/telesign.js | 2 +- javascript/src/functions/security.js | 21 +- javascript/test/auth.test.js | 24 +- javascript/test/dispatch.test.js | 5 +- javascript/test/security.test.js | 2 +- javascript/test/sendotp.test.js | 36 ++- python/README.md | 4 +- python/function_app.py | 196 ++++++++++---- python/src/cyot.py | 73 ++++-- python/src/dispatch.py | 10 +- python/src/providers/infobip.py | 4 +- python/src/providers/sinch.py | 2 +- python/src/providers/soprano.py | 12 +- python/src/providers/telesign.py | 2 +- python/src/registry.py | 2 +- python/src/security.py | 16 +- python/tests/test_cyot.py | 23 +- python/tests/test_engine.py | 5 +- 44 files changed, 894 insertions(+), 471 deletions(-) create mode 100644 javascript/src/functions/config.js diff --git a/README.md b/README.md index 879fe57..7d8c70e 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,16 @@ and deploying, step by step. `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`. +outcome and an HTTP status. **Fail-closed:** only a `Continue` outcome returns `200 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 +Set **`EPP_REQUIRE_AUTH=true`** in any real deployment. The Function then validates the caller's **Entra +JWT** (audience = `EPP_EXPECTED_AUDIENCE`, issuer tenant = `EPP_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 +identity** — no keys or connection strings in code or config. Locally, keep `EPP_REQUIRE_AUTH=false`. See [docs/ONBOARDING.md §6](docs/ONBOARDING.md) for how to test it with a token. ## Docs diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index ded261d..02ac19a 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -5,7 +5,7 @@ must conform to. If an implementation disagrees with this document, the implemen > **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`. +> `CyotDeliveryContext`) and the caller's `User-Agent`. App settings use the `EPP_` prefix. 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. @@ -15,7 +15,7 @@ provider is adding one adapter file; adding a language is re-implementing this c ## 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)** +the Entra token when `EPP_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. @@ -23,7 +23,7 @@ JWE; the cleartext envelope carries routing/scheduling only. | Header | Notes | |--------|-------| -| `Authorization` | `Bearer ` (audience = `EXPECTED_AUDIENCE`) | +| `Authorization` | `Bearer ` (audience = `EPP_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`) | @@ -37,7 +37,7 @@ JWE; the cleartext envelope carries routing/scheduling only. | `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 | +| `ttlSeconds` | | passcode validity remaining; `<= 0` is **logged as a warning** — the delivery still proceeds | | `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`. @@ -45,7 +45,7 @@ JWE; the cleartext envelope carries routing/scheduling only. ### `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 +endpoint resolves the matching RSA private key (`EPP_DECRYPTION_KEY_PEM`, a Key Vault reference) and 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`: @@ -54,7 +54,7 @@ verified before any plaintext is used. Decrypted plaintext = `CyotDeliveryContex |-------|----------|-------| | `nonce` | ✅ | value the endpoint MUST echo to prove decryption | | `phoneNumber` | ✅ | E.164, single canonical string | -| `message` | ✅ | fully rendered + localized text; **contains the passcode** | +| `message` | ✅ | fully rendered + localized text; **contains the passcode**. For `voice`, the passcode digits are spaced so TTS reads them individually | | `extension` | | office voice only | | `locale` | | selects TTS voice for the voice channel | | `riskContext` | | `CyotRiskContext` (scenario, familiarity flags, ip/asn/geo, ja4/ja4h, …) | @@ -68,9 +68,9 @@ Decryption failure → `400`. Missing `nonce` / `phoneNumber` / `message` → `4 ``` `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. +The endpoint returns **`200`** on acceptance (any `2xx` counts as transport 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 `200` + nonce echo without delivering. --- @@ -81,7 +81,7 @@ 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`) | +| `Continue` | `200` | recognized success status | | `Block` | `403` | provider says blocked | | `StepUp` | `409` | provider signals step-up / fraud escalation | | `Fail` | `429` | provider returned 429 | @@ -116,17 +116,21 @@ Set by provisioning. **Identical names across all languages.** | Key | Purpose | |-----|---------| -| `DEFAULT_PROVIDER` | active provider id | -| `_ENDPOINT`, `_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) | +| `EPP_PROVIDER_NAME` | active provider id (`infobip` \| `telesign` \| `sinch` \| `soprano`) | +| `EPP_PROVIDER_ENDPOINT` | provider base URL (one provider is active per deployment) | +| `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider | +| `EPP_PROVIDER_TIMEOUT_MS` | outbound call timeout (default 1500) | +| `EPP_PROVIDER_RETRY_INTERVAL_MS` | retry interval, reported at startup | +| `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 | +| `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | +| `EPP_REQUIRE_AUTH` | `true` → validate the Entra token in-process (Easy Auth is the primary gate) | +| `EPP_EXPECTED_AUDIENCE` | v1 token `aud` — the identifier URI `api://{host}/{appId}` | +| `EPP_EXPECTED_ISSUER` | v1 issuer `https://sts.windows.net/{tenantId}/` | +| `EPP_TENANT_ID` | your Entra tenant id | +| `EPP_EXPECTED_CLIENT_ID` | caller `appid` Easy Auth should admit — Microsoft's app `25ec60fa-f18d-41a4-b398-50044c90ce13`; a mismatch returns `403` | +| `EPP_LOG_PLAINTEXT` | **diagnostics only** — `true` writes the phone number and passcode to the log. Never enable in production | +| `KEY_VAULT_URL` | Key Vault URI (provider API keys) | | `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 @@ -136,13 +140,16 @@ User* role). Never in code or config. ## 5. Required behaviors -- **Fail-closed** — only `Continue` → `202 accepted`; unknown status → `Fail`. +- **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** — 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). + appear only in the outbound provider request, which is the delivery itself). The single exception is + `EPP_LOG_PLAINTEXT=true`, a **diagnostics-only** switch that logs the phone number, message, and + passcode. It defaults to false and **must not be enabled in production**. +- **Auth** — Easy Auth is the primary gate; `EPP_EXPECTED_CLIENT_ID` mismatches return `403`. When + `EPP_REQUIRE_AUTH=true`, also validate the Entra JWT in-process (audience = `EPP_EXPECTED_AUDIENCE`, + issuer tenant = `EPP_TENANT_ID`, RS256, JWKS). No-op pass-through when false (local dev). --- @@ -159,5 +166,5 @@ Every implementation ships tests covering at least: `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. +8. `Evaluation` mode → 200 + nonce echo, nothing sent. 9. Privacy: OTP code and phone never in logs or response body. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 288332a..292f897 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -25,16 +25,17 @@ 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` +in the encrypted JWE — see [CONTRACT.md](CONTRACT.md)). A **`200`** with the echoed `nonce` (`{ "nonce": "", "correlationId": "", "providerStatus": "accepted" }`) means the provider **queued** it — delivery is asynchronous, so confirm via the provider's delivery report. -## 5. Secure it — `REQUIRE_AUTH` +## 5. Secure it — Easy 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. +App Service Authentication is the gate: set `unauthenticatedClientAction` to `Return401` and list +Microsoft's application in `allowedApplications`, and anything else is rejected before your code runs. +Where Easy Auth is not available, set **`EPP_REQUIRE_AUTH=true`** (plus `EPP_EXPECTED_AUDIENCE` and +`EPP_TENANT_ID`) to validate the **Entra JWT** in-process instead. To test, obtain a token for the +expected audience and confirm: no token → 401, valid token → 200. ## 6. Deploy diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index be85554..f0970a1 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -1,20 +1,27 @@ { - "_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.", + "_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. Provider API keys are NOT here; they live in Key Vault. EPP_DECRYPTION_KEY_PEM is a Key Vault reference in Azure.", "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "node | dotnet-isolated | python", - "DEFAULT_PROVIDER": "", - "KEY_VAULT_URL": "https://.vault.azure.net/", - "EUDB": "false", - "ENDPOINT_TIMEOUT_MS": "1500", + "EPP_DECRYPTION_KEY_PEM": "", + "EPP_ENCRYPTION_KEY_ID": "", - "REQUIRE_AUTH": "false", - "EXPECTED_AUDIENCE": "", - "ISSUER_TENANT_ID": "", + "EPP_REQUIRE_AUTH": "false", + "EPP_EXPECTED_AUDIENCE": "api:///", + "EPP_EXPECTED_CLIENT_ID": "25ec60fa-f18d-41a4-b398-50044c90ce13", + "EPP_EXPECTED_ISSUER": "https://sts.windows.net//", + "EPP_TENANT_ID": "", - "_ENDPOINT": "https://", - "_ENDPOINT_EUDB": "https://", - "_SENDER_ID": "" + "EPP_PROVIDER_NAME": "", + "EPP_PROVIDER_ENDPOINT": "https://", + "EPP_PROVIDER_ACCOUNT_NAME": "", + "EPP_PROVIDER_TIMEOUT_MS": "1500", + "EPP_PROVIDER_RETRY_INTERVAL_MS": "0", + + "_comment_log_plaintext": "DIAGNOSTICS ONLY. true writes the phone number and passcode to the log. Never enable in production.", + "EPP_LOG_PLAINTEXT": "false", + + "KEY_VAULT_URL": "https://.vault.azure.net/" } } diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index 95a5208..155cc06 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; @@ -6,113 +7,215 @@ namespace Cyot.Otp; -// HTTP trigger: POST /api/SendOtp — the SAS → CYOT delivery endpoint. Validates the Entra token, parses -// the cleartext routing envelope, decrypts the JWE delivery context (PII lives there), dispatches to the -// provider, and echoes the nonce to prove decryption. Privacy: phone and OTP code are never logged or -// returned; the response body is the minimal CyotEndpointResponse. +// HTTP trigger: POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the +// caller, parses the cleartext routing envelope, decrypts the JWE delivery context (PII lives there), +// dispatches to the provider, and echoes the nonce to prove decryption. +// +// Every line starts with [EPP], so a whole delivery can be pulled out of a noisy log with one filter: +// Application Insights : traces | where message startswith "[EPP]" | order by timestamp asc public sealed class SendOtp { + private const string Tag = "[EPP]"; + private readonly DispatchEngine _engine; private readonly TokenValidator _tokens; private readonly JweDecryptor _decryptor; + private readonly IEnv _env; private readonly ILogger _log; - public SendOtp(DispatchEngine engine, TokenValidator tokens, JweDecryptor decryptor, ILogger log) + public SendOtp(DispatchEngine engine, TokenValidator tokens, JweDecryptor decryptor, IEnv env, ILogger log) { _engine = engine; _tokens = tokens; _decryptor = decryptor; + _env = env; _log = log; } + // Easy Auth has already validated the token; this only records which identity actually arrived. + private static string? ReadCallerAppId(HttpRequest req) + { + var encoded = req.Headers["x-ms-client-principal"].FirstOrDefault(); + if (string.IsNullOrEmpty(encoded)) return null; + try + { + using var doc = JsonDocument.Parse(Convert.FromBase64String(encoded)); + if (!doc.RootElement.TryGetProperty("claims", out var claims) || claims.ValueKind != JsonValueKind.Array) + return null; + foreach (var claim in claims.EnumerateArray()) + { + var type = claim.TryGetProperty("typ", out var t) ? t.GetString() : null; + if (type is "appid" or "azp") + return claim.TryGetProperty("val", out var v) ? v.GetString() : null; + } + return null; + } + catch + { + return null; + } + } + + // Lifts the passcode out of the rendered sentence, purely so it is easy to eyeball in the log. + private static string? ExtractPasscode(string? message) => + string.IsNullOrEmpty(message) ? null : Regex.Match(message, @"\b\d{4,8}\b") is { Success: true } m ? m.Value : null; + + // Voice: left alone, a TTS engine reads 641895 as "six hundred forty-one thousand eight hundred + // ninety-five", which no user can type. Spacing the digits makes it read them one at a time. + private static string? SpacePasscodeForVoice(string? message) => + string.IsNullOrEmpty(message) ? message : Regex.Replace(message, @"\b\d{4,8}\b", m => string.Join(" ", m.Value.ToCharArray()), RegexOptions.None); + [Function("SendOtp")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "SendOtp")] HttpRequest req) { + var started = DateTimeOffset.UtcNow; var requestId = Guid.NewGuid().ToString("n"); var clientRequestId = req.Headers["x-ms-client-request-id"].FirstOrDefault() ?? requestId; var headerCorrelationId = req.Headers["x-ms-correlation-id"].FirstOrDefault(); + var logPlaintext = string.Equals(_env.Get("EPP_LOG_PLAINTEXT"), "true", StringComparison.OrdinalIgnoreCase); + var expectedKeyId = _env.Get("EPP_ENCRYPTION_KEY_ID"); + var expectedClientId = _env.Get("EPP_EXPECTED_CLIENT_ID"); - var auth = await _tokens.ValidateAsync(req.Headers.Authorization.FirstOrDefault()); - if (!auth.Ok) - { - _log.LogWarning("[AUTH_ERROR] requestId={RequestId} reason={Reason}", requestId, auth.Reason); - return new ObjectResult(new { error = "unauthorized", reason = auth.Reason, requestId }) { StatusCode = 401 }; - } + void Log(string label, object? value) => _log.LogInformation("{Tag} {Label}: {Value}", Tag, label.PadRight(18), value); - JsonElement payload; + _log.LogInformation("{Tag} ======== delivery received ========", Tag); + Log("invocation", requestId); + + string? correlationId = null; try { - using var doc = await JsonDocument.ParseAsync(req.Body); - payload = doc.RootElement.Clone(); - } - catch - { - _log.LogWarning("[ERROR] requestId={RequestId} invalid JSON body", requestId); - return new BadRequestObjectResult(new { error = "bad_request", reason = "invalid JSON body", requestId }); - } + var callerAppId = ReadCallerAppId(req); + Log("caller appid", callerAppId ?? "none (Easy Auth off, or called directly)"); - var (envelope, envelopeError) = EnvelopeParser.Parse(payload); - if (envelopeError is not null) - { - _log.LogWarning("[VALIDATION_ERROR] requestId={RequestId} {Reason}", requestId, envelopeError); - return new BadRequestObjectResult(new { error = "bad_request", reason = envelopeError, requestId }); - } + if (callerAppId is not null && !string.IsNullOrEmpty(expectedClientId) && callerAppId != expectedClientId) + { + _log.LogError("{Tag} caller {Caller} is not {Expected}. Easy Auth allowedApplications is not doing its job.", + Tag, callerAppId, expectedClientId); + return new ObjectResult(new { error = "unexpected_caller" }) { StatusCode = 403 }; + } - var correlationId = envelope!.CorrelationId ?? headerCorrelationId ?? requestId; + var auth = await _tokens.ValidateAsync(req.Headers.Authorization.FirstOrDefault()); + if (!auth.Ok) + { + _log.LogError("{Tag} token rejected: {Reason}", Tag, auth.Reason); + return new ObjectResult(new { error = "unauthorized", reason = auth.Reason, requestId }) { StatusCode = 401 }; + } - CyotDeliveryContext context; - try - { - context = _decryptor.Decrypt(envelope.EncryptedDeliveryContext); - } - catch (Exception ex) - { - _log.LogWarning("[DECRYPT_ERROR] requestId={RequestId} correlationId={CorrelationId} reason={Reason}", requestId, correlationId, ex.Message); - return new ObjectResult(new { error = "decryption_failed", correlationId, requestId }) { StatusCode = 400 }; - } + JsonElement payload; + try + { + using var doc = await JsonDocument.ParseAsync(req.Body); + payload = doc.RootElement.Clone(); + } + catch + { + _log.LogError("{Tag} body is not JSON", Tag); + return new BadRequestObjectResult(new { error = "bad_request", reason = "invalid JSON body", requestId }); + } - if (string.IsNullOrEmpty(context.Nonce) || string.IsNullOrEmpty(context.PhoneNumber) || string.IsNullOrEmpty(context.Message)) - { - _log.LogWarning("[VALIDATION_ERROR] requestId={RequestId} correlationId={CorrelationId} incomplete delivery context", requestId, correlationId); - return new ObjectResult(new { error = "bad_request", reason = "incomplete delivery context", correlationId, requestId }) { StatusCode = 400 }; - } + var (envelope, envelopeError) = EnvelopeParser.Parse(payload); + if (envelopeError is not null) + { + _log.LogError("{Tag} envelope rejected: {Reason}", Tag, envelopeError); + return new BadRequestObjectResult(new { error = "bad_request", reason = envelopeError, requestId }); + } - var evaluation = envelope.Mode == EnvelopeParser.ModeEvaluation; - var channel = EnvelopeParser.ChannelName(envelope.Channel)!; + Log("type", envelope!.Type); + Log("tenantId", envelope.TenantId); + Log("correlationId", envelope.CorrelationId); + Log("channel", envelope.Channel); + Log("mode", envelope.Mode); + Log("ttlSeconds", envelope.TtlSeconds); - // Respect ttlSeconds: don't start a live delivery for an already-expired passcode (contract §7). - if (!evaluation && envelope.TtlSeconds is <= 0) - { - _log.LogWarning("[EXPIRED] requestId={RequestId} correlationId={CorrelationId} ttl={Ttl}", requestId, correlationId, envelope.TtlSeconds); - return new ObjectResult(new { error = "request_expired", correlationId, requestId }) { StatusCode = 400 }; - } + correlationId = envelope.CorrelationId ?? headerCorrelationId ?? requestId; - _log.LogInformation( - "[SENDOTP] requestId={RequestId} caller={Caller} type={Type} tenant={Tenant} correlationId={CorrelationId} channel={Channel} mode={Mode} ttl={Ttl} phone=present message=present risk={Risk}", - requestId, auth.CallerObjectId ?? "n/a", envelope.Type ?? "n/a", envelope.TenantId ?? "n/a", correlationId, - envelope.Channel, envelope.Mode, envelope.TtlSeconds?.ToString() ?? "n/a", context.RiskContext.HasValue ? "present" : "absent"); + // Surfaced rather than swallowed: the passcode expires before it can be used, so delivering + // it would only produce a failed sign-in and a support call. + if (envelope.TtlSeconds is <= 0) + _log.LogWarning("{Tag} ttlSeconds is {Ttl}; the passcode has expired.", Tag, envelope.TtlSeconds); - var dispatch = new DispatchRequest( - Destination: context.PhoneNumber!, - Message: context.Message, - Channel: channel, - MessageId: clientRequestId, - CorrelationId: correlationId, - Locale: context.Locale); + JweResult decrypted; + try + { + decrypted = _decryptor.Decrypt(envelope.EncryptedDeliveryContext); + } + catch (Exception ex) + { + _log.LogError("{Tag} decryption failed: {Reason}", Tag, ex.Message); + return new ObjectResult(new { error = "decryption_failed", correlationId, requestId }) { StatusCode = 400 }; + } - try - { - var result = await _engine.DispatchAsync(dispatch, null, evaluation, requestId, _log); - // Contract: acceptance is 202 Accepted (async delivery); the engine signals acceptance as 200. - var accepted = result.HttpStatus == 200; - return new ObjectResult(new { nonce = context.Nonce, correlationId, providerStatus = accepted ? "accepted" : "failed" }) - { StatusCode = accepted ? 202 : result.HttpStatus }; + var kidMatches = string.IsNullOrEmpty(expectedKeyId) || decrypted.Kid == expectedKeyId; + Log("kid", $"{decrypted.Kid}{(kidMatches ? "" : " (DOES NOT match EPP_ENCRYPTION_KEY_ID)")}"); + Log("alg / enc", $"{decrypted.Alg} / {decrypted.Enc}"); + Log("decrypted", "OK"); + + var context = decrypted.Context; + Log("nonce", context.Nonce); + + if (logPlaintext) + { + // DIAGNOSTICS ONLY — writes the phone number and passcode to the log. + Log("phoneNumber", context.PhoneNumber); + Log("extension", context.Extension ?? "(none)"); + Log("locale", context.Locale); + Log("message", context.Message); + Log("passcode", ExtractPasscode(context.Message) ?? "(none found)"); + Log("riskContext", context.RiskContext.HasValue ? context.RiskContext.Value.ToString() : "(none)"); + } + else + { + _log.LogInformation("{Tag} plaintext suppressed (EPP_LOG_PLAINTEXT=false)", Tag); + } + + if (string.IsNullOrEmpty(context.Nonce) || string.IsNullOrEmpty(context.PhoneNumber) || string.IsNullOrEmpty(context.Message)) + { + _log.LogError("{Tag} delivery context is incomplete (nonce/phoneNumber/message)", Tag); + return new ObjectResult(new { error = "bad_request", reason = "incomplete delivery context", correlationId, requestId }) { StatusCode = 400 }; + } + + var evaluation = envelope.Mode == EnvelopeParser.ModeEvaluation; + var channel = EnvelopeParser.ChannelName(envelope.Channel)!; + + var dispatch = new DispatchRequest( + Destination: context.PhoneNumber!, + Message: channel == "voice" ? SpacePasscodeForVoice(context.Message) : context.Message, + Channel: channel, + MessageId: clientRequestId, + CorrelationId: correlationId, + Locale: context.Locale); + + // Microsoft allows 3.2 s for the whole call, so the provider is called after the response. + var deliveryCorrelationId = correlationId; + _ = Task.Run(async () => + { + try + { + var result = await _engine.DispatchAsync(dispatch, null, evaluation, requestId, _log); + _log.LogInformation("{Tag} provider result : httpStatus={Status} correlationId={CorrelationId}", + Tag, result.HttpStatus, deliveryCorrelationId); + } + catch (Exception ex) + { + _log.LogError("{Tag} provider delivery failed: {Error}", Tag, ex.Message); + } + }); + + // Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery + // and Microsoft re-sends over its own telephony, so the user gets the code twice. + Log("responding", $"200, nonce echoed, {(DateTimeOffset.UtcNow - started).TotalMilliseconds:F0} ms"); + _log.LogInformation("{Tag} ======== done ========", Tag); + + return new ObjectResult(new { nonce = context.Nonce, correlationId, providerStatus = "accepted" }) + { StatusCode = 200 }; } catch (Exception ex) { - _log.LogError("[EXCEPTION] requestId={RequestId} error={Error}", requestId, ex.Message); - return new ObjectResult(new { nonce = context.Nonce, correlationId, providerStatus = "failed" }) { StatusCode = 500 }; + // Verbose on purpose: this endpoint exists to diagnose onboarding. + _log.LogError("{Tag} FAILED after {Elapsed} ms: {Error}", Tag, (DateTimeOffset.UtcNow - started).TotalMilliseconds, ex.Message); + _log.LogInformation("{Tag} ======== failed ========", Tag); + return new ObjectResult(new { error = "delivery_failed", detail = ex.Message, correlationId }) { StatusCode = 500 }; } } } diff --git a/dotnet/Program.cs b/dotnet/Program.cs index d82586e..dc64713 100644 --- a/dotnet/Program.cs +++ b/dotnet/Program.cs @@ -13,7 +13,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Provider adapters — add one line to onboard a provider. diff --git a/dotnet/README.md b/dotnet/README.md index f4af120..a5ac220 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -14,14 +14,14 @@ dotnet/ │ ├─ CyotEnvelope.cs # parse/validate the cleartext SAS → CYOT envelope │ ├─ DeliveryContext.cs # JWE decrypt (RSA-OAEP-256 + A256GCM) → CyotDeliveryContext │ ├─ DispatchEngine.cs # resolve provider → credential → endpoint → send → outcome -│ ├─ ProviderRegistry.cs # keyed adapter registry + DEFAULT_PROVIDER resolution +│ ├─ ProviderRegistry.cs # keyed adapter registry + EPP_PROVIDER_NAME resolution │ ├─ IProviderAdapter.cs # Manifest + BuildRequest + ParseResponse │ ├─ Providers/*.cs # infobip, telesign, soprano, sinch │ ├─ SecretResolver.cs # Key Vault via managed identity (cached) │ ├─ ISecretResolver.cs # secret-resolver abstraction (injectable for tests) │ ├─ OutcomeMapper.cs # status → outcome → HTTP status │ ├─ Models.cs # DispatchRequest + shared records -│ └─ TokenValidator.cs # Entra JWT validation when REQUIRE_AUTH=true +│ └─ TokenValidator.cs # Entra JWT validation when EPP_REQUIRE_AUTH=true └─ tests/ # xUnit conformance tests ``` diff --git a/dotnet/Src/DeliveryContext.cs b/dotnet/Src/DeliveryContext.cs index c973d13..afe3252 100644 --- a/dotnet/Src/DeliveryContext.cs +++ b/dotnet/Src/DeliveryContext.cs @@ -16,7 +16,10 @@ public sealed class CyotDeliveryContext [JsonPropertyName("riskContext")] public JsonElement? RiskContext { get; set; } } -// Supplies the RSA private key for the JWE `kid`. Injectable so tests use a local key, not Key Vault. +// Result of a successful decrypt: the protected header (for kid/alg logging) plus the plaintext context. +public sealed record JweResult(string? Kid, string? Alg, string? Enc, CyotDeliveryContext Context); + +// Supplies the RSA private key for the JWE `kid`. Injectable so tests use a local key. public interface IJweKeyProvider { RSA GetPrivateKey(string? kid); @@ -32,14 +35,18 @@ public sealed class JweDecryptor // Reject oversized or structurally invalid JWEs before base64-decoding or allocating buffers. private const int MaxJweLength = 16384; - public CyotDeliveryContext Decrypt(string compactJwe) + public JweResult Decrypt(string compactJwe) { AssertWellFormed(compactJwe); - var kid = ReadKid(compactJwe); + var headers = Jose.JWT.Headers(compactJwe); + var kid = headers.TryGetValue("kid", out var kidValue) ? kidValue?.ToString() : null; + var alg = headers.TryGetValue("alg", out var algValue) ? algValue?.ToString() : null; + var enc = headers.TryGetValue("enc", out var encValue) ? encValue?.ToString() : null; var rsa = _keys.GetPrivateKey(kid); // Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). var plaintext = Jose.JWT.Decrypt(compactJwe, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); - return JsonSerializer.Deserialize(plaintext) ?? new CyotDeliveryContext(); + var context = JsonSerializer.Deserialize(plaintext) ?? new CyotDeliveryContext(); + return new JweResult(kid, alg, enc, context); } // Contract: exactly five non-empty compact segments; alg/enc/IV/tag are enforced by the JWE decrypt. @@ -53,40 +60,38 @@ private static void AssertWellFormed(string compactJwe) if (segments.Length != 5 || Array.Exists(segments, string.IsNullOrEmpty)) throw new InvalidOperationException("malformed JWE: expected five non-empty segments"); } - - public static string? ReadKid(string compactJwe) - { - var headers = Jose.JWT.Headers(compactJwe); - return headers.TryGetValue("kid", out var kid) ? kid?.ToString() : null; - } } -// Default key source: an inline PEM (CYOT_JWE_PRIVATE_KEY_PEM, local/dev) or a Key Vault secret -// (name = JWE_PRIVATE_KEY_SECRET, else the `kid`). -public sealed class KeyVaultJweKeyProvider : IJweKeyProvider +// Key source: the EPP_DECRYPTION_KEY_PEM app setting, which is a Key Vault reference — the runtime only +// ever sees the resolved PEM. Imported once, because doing it per delivery would add an RSA import +// inside the response budget and turn a bad key into a failure on every call. +public sealed class EnvJweKeyProvider : IJweKeyProvider { - private readonly ISecretResolver _secrets; private readonly IEnv _env; + private RSA? _cached; + private string? _cachedPem; - public KeyVaultJweKeyProvider(ISecretResolver secrets, IEnv env) - { - _secrets = secrets; - _env = env; - } + public EnvJweKeyProvider(IEnv env) => _env = env; public RSA GetPrivateKey(string? kid) { - var pem = _env.Get("CYOT_JWE_PRIVATE_KEY_PEM"); - if (string.IsNullOrEmpty(pem)) - { - var secretName = _env.Get("JWE_PRIVATE_KEY_SECRET") ?? kid; - pem = string.IsNullOrEmpty(secretName) ? null : _secrets.ResolveAsync(secretName).GetAwaiter().GetResult(); - } + var pem = _env.Get("EPP_DECRYPTION_KEY_PEM"); if (string.IsNullOrEmpty(pem)) - throw new InvalidOperationException("private key unavailable"); + throw new InvalidOperationException("private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)"); + + if (_cached is not null && _cachedPem == pem) return _cached; var rsa = RSA.Create(); - rsa.ImportFromPem(pem); + rsa.ImportFromPem(NormalizePem(pem)); + _cached = rsa; + _cachedPem = pem; return rsa; } + + // The setup script stores the key as base64 over the PEM so its newlines survive being carried as a + // secret and then as an app setting, so accept either form. + private static string NormalizePem(string value) => + value.Contains("-----BEGIN", StringComparison.Ordinal) + ? value + : System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(value.Trim())); } diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index e158695..134a382 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -61,7 +61,7 @@ public async Task DispatchAsync(DispatchRequest dispatch, string if (shutter) return new DispatchResult(200, new { status = "accepted", shutterProcessed = true, provider = providerId, channel, correlationId = dispatch.CorrelationId, messageId = dispatch.MessageId, requestId }); - var timeoutMs = int.TryParse(_env.Get("ENDPOINT_TIMEOUT_MS"), out var parsedTimeout) ? parsedTimeout : DefaultTimeoutMs; + var timeoutMs = int.TryParse(_env.Get("EPP_PROVIDER_TIMEOUT_MS"), out var parsedTimeout) ? parsedTimeout : DefaultTimeoutMs; HttpResponseMessage resp; string body; try @@ -113,15 +113,9 @@ private async Task ResolveCredentialAsync(AuthConfig auth) return new ProviderCredential("apiKey", Secret: secret, Identity: identity); } - // Base URL from app settings: _ENDPOINT_EUDB when EUDB=true, else _ENDPOINT. - private static string? ResolveEndpoint(ProviderManifest manifest, IEnv env) - { - var idUpper = manifest.Id.ToUpperInvariant(); - var useEudb = string.Equals(env.Get("EUDB"), "true", StringComparison.OrdinalIgnoreCase); - var eudb = env.Get($"{idUpper}_ENDPOINT_EUDB"); - if (useEudb && !string.IsNullOrEmpty(eudb)) return eudb; - return env.Get($"{idUpper}_ENDPOINT"); - } + // Base URL from app settings: one provider is active per deployment, so the endpoint is a single + // EPP_PROVIDER_ENDPOINT rather than a per-provider key. + private static string? ResolveEndpoint(ProviderManifest manifest, IEnv env) => env.Get("EPP_PROVIDER_ENDPOINT"); private async Task<(HttpResponseMessage, string)> SendAsync(ProviderHttpRequest req, int timeoutMs) { diff --git a/dotnet/Src/ProviderRegistry.cs b/dotnet/Src/ProviderRegistry.cs index eea0865..0151043 100644 --- a/dotnet/Src/ProviderRegistry.cs +++ b/dotnet/Src/ProviderRegistry.cs @@ -21,7 +21,7 @@ public ProviderRegistry(IEnumerable adapters) { var id = !string.IsNullOrWhiteSpace(requestProvider) ? requestProvider - : Environment.GetEnvironmentVariable("DEFAULT_PROVIDER"); + : Environment.GetEnvironmentVariable("EPP_PROVIDER_NAME"); return Get(id); } } diff --git a/dotnet/Src/Providers/InfobipProvider.cs b/dotnet/Src/Providers/InfobipProvider.cs index 38573b6..9e08f6e 100644 --- a/dotnet/Src/Providers/InfobipProvider.cs +++ b/dotnet/Src/Providers/InfobipProvider.cs @@ -21,7 +21,7 @@ public sealed class InfobipProvider : IProviderAdapter public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { - var senderId = env.Get("INFOBIP_SENDER_ID") ?? "Verify"; + var senderId = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? "Verify"; var auth = credential.Mode == "oauth2" ? $"Bearer {credential.Token}" : $"App {credential.Secret}"; var headers = new Dictionary { @@ -39,7 +39,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc { new { - from = env.Get("INFOBIP_VOICE_FROM") ?? senderId, + from = senderId, destinations = new[] { new { to = dispatch.Destination, messageId } }, text = dispatch.Message, language = dispatch.Locale ?? "en", diff --git a/dotnet/Src/Providers/SinchProvider.cs b/dotnet/Src/Providers/SinchProvider.cs index 9075da2..df4db37 100644 --- a/dotnet/Src/Providers/SinchProvider.cs +++ b/dotnet/Src/Providers/SinchProvider.cs @@ -49,7 +49,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc var servicePlanId = env.Get("SINCH_SERVICE_PLAN_ID") ?? string.Empty; var body = new { - from = env.Get("SINCH_SENDER_ID") ?? "Verify", + from = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? "Verify", to = new[] { dispatch.Destination }, body = dispatch.Message, client_reference = reference, diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index e4b8fd6..f9ebe70 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -29,12 +29,14 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc if (credential.Mode == "oauth2") headers["Authorization"] = $"Bearer {credential.Token}"; else { headers["X-MEMS-API-ID"] = credential.Identity ?? string.Empty; headers["X-MEMS-API-Key"] = credential.Secret ?? string.Empty; } + // Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A + // non-numeric account name is sent as a free-text source instead. object endpoints_or_source() { - var sourceId = env.Get("SOPRANO_SOURCE_ID"); - if (!string.IsNullOrEmpty(sourceId)) - return new { endpoints = new[] { new { type = int.TryParse(env.Get("SOPRANO_SOURCE_TYPE"), out var parsedSourceType) ? parsedSourceType : 1, id = int.Parse(sourceId) } } }; - return new { source = env.Get("SOPRANO_SENDER_ID") }; + var account = env.Get("EPP_PROVIDER_ACCOUNT_NAME"); + if (!string.IsNullOrEmpty(account) && int.TryParse(account, out var sourceId)) + return new { endpoints = new[] { new { type = int.TryParse(env.Get("SOPRANO_SOURCE_TYPE"), out var parsedSourceType) ? parsedSourceType : 1, id = sourceId } } }; + return new { source = account }; } var clientRef = dispatch.CorrelationId ?? dispatch.MessageId; diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs index 8f66098..2821aba 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -46,7 +46,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc path = "/v1/messaging"; form["phone_number"] = dispatch.Destination; form["message"] = dispatch.Message ?? string.Empty; - form["sender_id"] = env.Get("TELESIGN_SENDER_ID") ?? string.Empty; + form["sender_id"] = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? string.Empty; form["message_type"] = "OTP"; form["external_id"] = externalId; form["is_primary"] = "true"; diff --git a/dotnet/Src/TokenValidator.cs b/dotnet/Src/TokenValidator.cs index 70bc8b7..a780649 100644 --- a/dotnet/Src/TokenValidator.cs +++ b/dotnet/Src/TokenValidator.cs @@ -16,11 +16,11 @@ public sealed record Result(bool Ok, string? Reason = null, string? CallerObject public async Task ValidateAsync(string? authorizationHeader) { - if (!string.Equals(Environment.GetEnvironmentVariable("REQUIRE_AUTH"), "true", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(Environment.GetEnvironmentVariable("EPP_REQUIRE_AUTH"), "true", StringComparison.OrdinalIgnoreCase)) return new Result(true); - var audience = Environment.GetEnvironmentVariable("EXPECTED_AUDIENCE"); - var tenantId = Environment.GetEnvironmentVariable("ISSUER_TENANT_ID"); + var audience = Environment.GetEnvironmentVariable("EPP_EXPECTED_AUDIENCE"); + var tenantId = Environment.GetEnvironmentVariable("EPP_TENANT_ID"); if (string.IsNullOrEmpty(audience) || string.IsNullOrEmpty(tenantId)) return new Result(false, "auth misconfigured"); @@ -35,10 +35,15 @@ public async Task ValidateAsync(string? authorizationHeader) try { var config = await _configManager.GetConfigurationAsync(); + // EPP_EXPECTED_ISSUER pins one issuer; otherwise accept both the v2 and v1 forms. + var pinnedIssuer = Environment.GetEnvironmentVariable("EPP_EXPECTED_ISSUER"); + var validIssuers = string.IsNullOrEmpty(pinnedIssuer) + ? new[] { $"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/" } + : new[] { pinnedIssuer }; var parameters = new TokenValidationParameters { ValidateIssuer = true, - ValidIssuers = new[] { $"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/" }, + ValidIssuers = validIssuers, ValidateAudience = true, ValidAudience = audience, ValidateLifetime = true, diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 69d3e93..3f94c91 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -35,7 +35,7 @@ public void OutcomeMappingAndHttpStatus() [Fact] public void InfobipBuildsHttpsSmsRequestWithAppAuthAndCode() { - var env = new FakeEnv { ["INFOBIP_SENDER_ID"] = "CYOT" }; + var env = new FakeEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "CYOT" }; var req = new InfobipProvider().BuildRequest("sms", "https://api.infobip.com", Disp(message: "Use verification code 918273 for Microsoft authentication."), new ProviderCredential("apiKey", Secret: "ib"), env); diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 9e087f8..99060c4 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -60,8 +60,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except private static FakeEnv DefaultEnv() => new() { - ["INFOBIP_ENDPOINT"] = "https://api.infobip.com", - ["TELESIGN_ENDPOINT"] = "https://rest-api.telesign.com", + ["EPP_PROVIDER_ENDPOINT"] = "https://api.infobip.com", }; private static DispatchEngine Engine(HttpResponseMessage? response = null, Exception? throwOnSend = null, diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs index 139f11c..ea7f0a9 100644 --- a/dotnet/tests/EnvelopeTests.cs +++ b/dotnet/tests/EnvelopeTests.cs @@ -67,9 +67,11 @@ public void Jwe_RoundTrips_ToDeliveryContext() var jwe = Jose.JWT.Encode(contextJson, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM, extraHeaders: new Dictionary { ["kid"] = "test-key" }); - Assert.Equal("test-key", JweDecryptor.ReadKid(jwe)); - - var context = new JweDecryptor(new FakeKeyProvider(rsa)).Decrypt(jwe); + var decrypted = new JweDecryptor(new FakeKeyProvider(rsa)).Decrypt(jwe); + Assert.Equal("test-key", decrypted.Kid); + Assert.Equal("RSA-OAEP-256", decrypted.Alg); + Assert.Equal("A256GCM", decrypted.Enc); + var context = decrypted.Context; Assert.Equal("nonce-1", context.Nonce); Assert.Equal("+14255551234", context.PhoneNumber); Assert.Equal("Your code is 123456", context.Message); diff --git a/javascript/README.md b/javascript/README.md index 90043fe..878c6ef 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -9,16 +9,16 @@ provider (**Infobip**, **Telesign**, **Sinch**, or **Soprano**). - Provider secrets from **Azure Key Vault** (managed identity). - **Correlation id** propagated to the provider and echoed back. - **Shutter mode** — process the full path but do not send. -- **Optional token validation** — off by default; enable with `REQUIRE_AUTH=true`. +- **Optional in-process token validation** — off by default; enable with `EPP_REQUIRE_AUTH=true`. -> Token validation exists but is **off by default** — enable it in any real deployment (`REQUIRE_AUTH=true`). +> Token validation exists but is **off by default** — enable it in any real deployment (`EPP_REQUIRE_AUTH=true`). ## Deploy 1. **Create the Key Vault** and add what the Function reads: - the provider **API key/token** as a **secret** (default name `infobip-api-key` — see [Configuration](#configuration)). 2. **Grant the Function's managed identity** on that vault: **Key Vault Secrets User**. -3. **Set the app settings** — copy [`../docs/local.settings.sample.json`](../docs/local.settings.sample.json) into `src/local.settings.json` locally; in Azure set them under **Function App → Settings → Environment variables**. At minimum set `KEY_VAULT_URL` and `DEFAULT_PROVIDER`; see [Configuration](#configuration) for the full list. +3. **Set the app settings** — copy [`../docs/local.settings.sample.json`](../docs/local.settings.sample.json) into `src/local.settings.json` locally; in Azure set them under **Function App → Settings → Environment variables**. At minimum set `EPP_DECRYPTION_KEY_PEM`, `EPP_PROVIDER_NAME`, and `EPP_PROVIDER_ENDPOINT`; see [Configuration](#configuration) for the full list. 4. **Publish:** ```bash @@ -40,8 +40,8 @@ the only provider-specific parts are its **adapter** (the outbound API call) and > **Provisioning model.** The provider's authoritative parameters live in its **Security Store package > manifest**. At provisioning time, UX reads that manifest and sets the operational values as **app -> settings (env properties)** on the Function — endpoint URLs (`_ENDPOINT`, `EUDB`), sender/source -> IDs, `ENDPOINT_TIMEOUT_MS`, and the Key Vault secret references. The values baked +> settings (env properties)** on the Function — the endpoint URL (`EPP_PROVIDER_ENDPOINT`), sender/source +> id (`EPP_PROVIDER_ACCOUNT_NAME`), `EPP_PROVIDER_TIMEOUT_MS`, and the Key Vault secret references. The values baked > into `providers/.js` are just **local-dev defaults**; the app settings win. Only the **adapter > code** (`buildRequest`/`parseResponse`) is provider-specific code — everything else is data. @@ -54,34 +54,40 @@ Key Vault and can be rotated there without a redeploy. | Key | Purpose | |-----|---------| -| `DEFAULT_PROVIDER` | your chosen provider: `infobip` \| `telesign` \| `sinch` \| `soprano` | -| `KEY_VAULT_URL` | Key Vault URI (required) | -| `EUDB` | `true` for an EU Data Boundary deployment — uses each provider's EU endpoint URL (optional) | -| `ENDPOINT_TIMEOUT_MS` | outbound provider-call timeout in ms (default `1500`) | -| `REQUIRE_AUTH` | `true` to enforce token validation — enable in any real deployment | -| `EXPECTED_AUDIENCE` | token `aud` (this endpoint's app registration appId) — required when `REQUIRE_AUTH=true` | -| `ISSUER_TENANT_ID` | customer tenant id for issuer/JWKS — required when `REQUIRE_AUTH=true` | +| `EPP_PROVIDER_NAME` | your chosen provider: `infobip` \| `telesign` \| `sinch` \| `soprano` | +| `EPP_PROVIDER_ENDPOINT` | provider base URL (one provider is active per deployment) | +| `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider | +| `EPP_PROVIDER_TIMEOUT_MS` | outbound provider-call timeout in ms (default `1500`) | +| `EPP_PROVIDER_RETRY_INTERVAL_MS` | retry interval in ms (reported at startup) | +| `EPP_DECRYPTION_KEY_PEM` | RSA private key PEM for JWE decryption — a **Key Vault reference** in Azure | +| `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | +| `EPP_EXPECTED_CLIENT_ID` | caller `appid` Easy Auth should admit; a mismatch returns `403` | +| `EPP_REQUIRE_AUTH` | `true` to also validate the token in-process — enable in any real deployment | +| `EPP_EXPECTED_AUDIENCE` | token `aud` (this endpoint's app registration appId) — required when `EPP_REQUIRE_AUTH=true` | +| `EPP_TENANT_ID` | customer tenant id for issuer/JWKS — required when `EPP_REQUIRE_AUTH=true` | +| `EPP_EXPECTED_ISSUER` | optional; pins a single issuer instead of accepting both v1 and v2 | +| `EPP_LOG_PLAINTEXT` | **diagnostics only** — `true` writes the phone number and passcode to the log. Never enable in production | +| `KEY_VAULT_URL` | Key Vault URI (provider API keys) | ### Per-provider settings (set only for the provider you chose) -Set `DEFAULT_PROVIDER` to your provider, then provision **only that block** — its **Key Vault secret** -(the API key/token — the *only* secret) plus its **non-secret app settings**: the endpoint -(`_ENDPOINT` / `_EUDB`), sender/source id, etc. Endpoints, sender ids, `KEY_VAULT_URL`, and the -Key Vault secret **names** are all non-secret configuration; only the key/token **value** lives in Key Vault. +Set `EPP_PROVIDER_NAME` to your provider, then provision **only that block** — its **Key Vault secret** +(the API key/token — the *only* secret) plus the shared `EPP_PROVIDER_ENDPOINT` / +`EPP_PROVIDER_ACCOUNT_NAME`. Endpoints, sender ids, `KEY_VAULT_URL`, and the Key Vault secret **names** +are all non-secret configuration; only the key/token **value** lives in Key Vault. **Infobip** | Setting | Purpose | |---------|---------| | Key Vault secret `infobip-api-key` | API key | -| `INFOBIP_SENDER_ID` | registered sender, app setting (default `Verify`) | -| `INFOBIP_VOICE_FROM` | voice caller id, app setting (optional; falls back to `INFOBIP_SENDER_ID`) | +| `INFOBIP_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | registered sender, app setting (default `Verify`) | **Telesign** | Setting | Purpose | |---------|---------| | Key Vault secret `telesign-api-key` | API key | | Key Vault secret `telesign-customer-id` | customer id (the Basic-auth username) | -| `TELESIGN_SENDER_ID` | sender id, app setting (optional) | +| `TELESIGN_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | sender id, app setting (optional) | | `TELESIGN_VOICE` | voice language/voice code for voice OTP, app setting (optional; default `f-en-US`) | **Sinch** @@ -89,7 +95,7 @@ Key Vault secret **names** are all non-secret configuration; only the key/token |---------|---------| | Key Vault secret `sinch-api-token` | API token | | `SINCH_SERVICE_PLAN_ID` | XMS service plan id, app setting | -| `SINCH_SENDER_ID` | sender, app setting (default `Verify`) | +| `SINCH_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | sender, app setting (default `Verify`) | | `SINCH_VOICE_ENDPOINT` | Sinch Voice API host, app setting (optional; default `https://calling.api.sinch.com`) | **Soprano** @@ -97,12 +103,12 @@ Key Vault secret **names** are all non-secret configuration; only the key/token |---------|---------| | Key Vault secret `soprano-api-key` | API key (sent as the `X-MEMS-API-Key` header) | | Key Vault secret `soprano-api-id` | API ID (sent as the `X-MEMS-API-ID` header) | -| `SOPRANO_ENDPOINT` | **required** — your MEMS API base `https:///cgpapi` (per-customer; no default) | +| `EPP_PROVIDER_ENDPOINT` | **required** — your MEMS API base `https:///cgpapi` (per-customer; no default) | | `SOPRANO_SOURCE_ID` | provisioned source/sender endpoint id, app setting — Soprano requires a provisioned sender, sent as `endpoints:[{type,id}]` | | `SOPRANO_SOURCE_TYPE` | provisioned source endpoint type, app setting (optional; default `1`) | -| `SOPRANO_SENDER_ID` | optional free-text sender, used only as a fallback when `SOPRANO_SOURCE_ID` is unset | +| `SOPRANO_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | optional free-text sender, used only as a fallback when `SOPRANO_SOURCE_ID` is unset | -> Optional per-provider `_ENDPOINT` overrides the manifest URL (e.g. a sandbox host); rarely needed. +> `EPP_PROVIDER_ENDPOINT` is the provider base URL for the one active provider (e.g. a sandbox host). ### Identity & permissions (managed identity — no static credentials) @@ -112,7 +118,7 @@ The Function authenticates to Key Vault (and any other Azure resource) with its |-------|------|-----| | The provider **secret** (or the vault) | **Key Vault Secrets User** | `get` the provider API key/token | -Also use an **identity-based** `AzureWebJobsStorage` connection (managed identity) instead of a storage connection string, so the runtime holds no static secret either. All resource **names** (`KEY_VAULT_URL`, `EXPECTED_AUDIENCE`, `ISSUER_TENANT_ID`) come from app settings — nothing is hard-coded. +Also use an **identity-based** `AzureWebJobsStorage` connection (managed identity) instead of a storage connection string, so the runtime holds no static secret either. All resource **names** (`KEY_VAULT_URL`, `EPP_EXPECTED_AUDIENCE`, `EPP_TENANT_ID`) come from app settings — nothing is hard-coded. ### Add your own provider @@ -151,7 +157,7 @@ function parseResponse({ httpStatus, ok, json }) { module.exports = { manifest, buildRequest, parseResponse }; ``` -The engine handles the rest — provider resolution, Key Vault credential fetch (via managed identity), message templating, timeout, `responseMapping` → HTTP status, and fail-closed behavior. Drop the file in, add the Key Vault secret, set `DEFAULT_PROVIDER=acme`, and it works. +The engine handles the rest — provider resolution, Key Vault credential fetch (via managed identity), message templating, timeout, `responseMapping` → HTTP status, and fail-closed behavior. Drop the file in, add the Key Vault secret, set `EPP_PROVIDER_NAME=acme`, and it works. ## Request contract @@ -167,14 +173,14 @@ PII (phone + rendered message, which contains the passcode) is encrypted in a JW | `encryptedDeliveryContext` | yes | JWE (RSA-OAEP-256 + A256GCM); decrypts to `{ nonce, phoneNumber, message, locale?, riskContext? }` | | `tenantId`, `correlationId`, `ttlSeconds` | no | routing / tracing / passcode validity | -The active provider is deployment config (`DEFAULT_PROVIDER`), not a request field. The response is the +The active provider is deployment config (`EPP_PROVIDER_NAME`), not a request field. The response is the `CyotEndpointResponse`: `{ "nonce": "", "correlationId": "", "providerStatus": "accepted" }`. A `2xx` with a matching nonce means handled; non-2xx / nonce mismatch / timeout → SAS falls back to CAPP. ## Try it The private RSA key that decrypts `encryptedDeliveryContext` is resolved from Key Vault by the JOSE `kid` -(or `CYOT_JWE_PRIVATE_KEY_PEM` for local dev). Build the envelope with the matching public key: +(`EPP_DECRYPTION_KEY_PEM`, a Key Vault reference). Build the envelope with the matching public key: ```bash curl -X POST https://.azurewebsites.net/api/SendOtp \ @@ -190,7 +196,7 @@ curl -X POST https://.azurewebsites.net/api/SendOtp \ "ttlSeconds": 60, "encryptedDeliveryContext": "" }' -# -> 202 { "nonce": "", "correlationId": "test-001", "providerStatus": "accepted" } +# -> 200 { "nonce": "", "correlationId": "test-001", "providerStatus": "accepted" } ``` Evaluation mode (`"mode": 2`) runs everything except the actual send and still echoes the nonce. diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index 8aa8c11..c363ff7 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -4,96 +4,201 @@ 'use strict'; -// POST /api/SendOtp — the SAS → CYOT delivery endpoint. Validates the Entra token, parses the cleartext -// routing envelope, decrypts the JWE delivery context (PII lives there), dispatches to the provider, and -// echoes the nonce to prove decryption. Privacy: the OTP code, phone digits, and decrypted context are -// never logged (presence only); the response body is the minimal CyotEndpointResponse. +// POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the caller, parses +// the cleartext routing envelope, decrypts the JWE delivery context (PII lives there), dispatches to the +// provider, and echoes the nonce to prove decryption. +// +// Every line starts with [EPP], so a whole delivery can be pulled out of a noisy log with one filter: +// Log stream : look for [EPP] +// Application Insights : traces | where message startswith "[EPP]" | order by timestamp asc const { app } = require('@azure/functions'); const crypto = require('crypto'); const { validateToken } = require('./security'); const { dispatchOtp } = require('./dispatch'); const { parseEnvelope, decryptDeliveryContext, contextToDispatch, MODE } = require('./cyot'); +const { readConfig, missingSettings } = require('./config'); + +const TAG = '[EPP]'; + +// Easy Auth has already validated the token by the time this runs; this only records which identity +// actually arrived, which is the first thing worth knowing when a delivery is refused. +function readCallerAppId(request) { + const encoded = request.headers.get('x-ms-client-principal'); + if (!encoded) return undefined; + try { + const principal = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); + const claim = (principal.claims || []).find((c) => c.typ === 'appid' || c.typ === 'azp'); + return claim && claim.val; + } catch { + return undefined; + } +} + +// Lifts the passcode out of the rendered sentence, purely so it is easy to eyeball in the log. +function extractPasscode(message) { + const match = /\b\d{4,8}\b/.exec(message || ''); + return match ? match[0] : undefined; +} + +const pad = (label) => label.padEnd(18, ' '); + +// The handler deliberately does not await the provider, so tests need a handle on the send it started. +let pendingDelivery = Promise.resolve(); +const whenDelivered = () => pendingDelivery; + +// Microsoft allows 3.2 s for the whole call, so the provider is called after the response. +function deliverInBackground(dispatch, envelope, evaluation, config, context, requestId) { + pendingDelivery = dispatchOtp(dispatch, { + tenantId: envelope.tenantId, + requestProvider: config.provider.name || undefined, + shutter: evaluation, + context, + requestId, + }) + .then(({ httpStatus }) => { + context.log(`${TAG} provider result : httpStatus=${httpStatus}`); + }) + .catch((deliveryError) => { + (context.error || context.log).call(context, `${TAG} provider delivery failed: ${deliveryError.message}`); + }); + return pendingDelivery; +} app.http('SendOtp', { methods: ['POST'], - authLevel: 'anonymous', + authLevel: 'anonymous', // Easy Auth is the gate; EPP_REQUIRE_AUTH adds in-process token validation. handler: async (request, context) => { - const callerOid = request.headers.get('x-ms-client-principal-id') || null; + const started = Date.now(); + const config = readConfig(); + const log = (label, value) => context.log(`${TAG} ${pad(label)}: ${value}`); + const warn = (message) => (context.warn || context.log).call(context, `${TAG} ${message}`); + const error = (message) => (context.error || context.log).call(context, `${TAG} ${message}`); + const requestId = crypto.randomUUID(); const clientRequestId = request.headers.get('x-ms-client-request-id') || requestId; const headerCorrelationId = request.headers.get('x-ms-correlation-id') || null; - const tokenValidation = await validateToken(request, context, requestId); - if (!tokenValidation.ok) { - context.log(`[AUTH_REJECT] requestId=${requestId} reason=${tokenValidation.reason}`); - return { status: 401, jsonBody: { error: 'unauthorized', reason: tokenValidation.reason, requestId } }; - } + context.log(`${TAG} ======== delivery received ========`); + log('invocation', context.invocationId || requestId); - let payload; - let rawBody; + let envelope; try { - rawBody = await request.text(); - payload = JSON.parse(rawBody); - } catch { - context.log(`[ERROR] requestId=${requestId} invalid JSON body bytes=${rawBody ? rawBody.length : 0}`); - return { status: 400, jsonBody: { error: 'bad_request', reason: 'invalid JSON body', requestId } }; - } + // Logged, not thrown: a missing provider setting still lets this prove decryption works. + const absent = missingSettings(config); + if (absent.length > 0) { + warn(`settings not set: ${absent.join(', ')}`); + } - const { envelope, error: envelopeError } = parseEnvelope(payload); - if (envelopeError) { - context.log(`[VALIDATION_ERROR] requestId=${requestId} ${envelopeError}`); - return { status: 400, jsonBody: { error: 'bad_request', reason: envelopeError, requestId } }; - } - const correlationId = envelope.correlationId || headerCorrelationId || requestId; + const callerAppId = readCallerAppId(request); + log('caller appid', callerAppId || 'none (Easy Auth off, or called directly)'); - let deliveryContext; - try { - deliveryContext = await decryptDeliveryContext(envelope.encryptedDeliveryContext, { env: process.env }); - } catch (decryptError) { - context.log(`[DECRYPT_ERROR] requestId=${requestId} correlationId=${correlationId} reason=${decryptError.message}`); - return { status: 400, jsonBody: { error: 'decryption_failed', correlationId, requestId } }; - } + if (callerAppId && config.expectedClientId && callerAppId !== config.expectedClientId) { + error(`caller ${callerAppId} is not ${config.expectedClientId}. ` + + 'Easy Auth allowedApplications is not doing its job.'); + return { status: 403, jsonBody: { error: 'unexpected_caller' } }; + } - if (!deliveryContext || !deliveryContext.nonce || !deliveryContext.phoneNumber || !deliveryContext.message) { - context.log(`[VALIDATION_ERROR] requestId=${requestId} correlationId=${correlationId} incomplete delivery context`); - return { status: 400, jsonBody: { error: 'bad_request', reason: 'incomplete delivery context', correlationId, requestId } }; - } + const tokenValidation = await validateToken(request, context, requestId); + if (!tokenValidation.ok) { + error(`token rejected: ${tokenValidation.reason}`); + return { status: 401, jsonBody: { error: 'unauthorized', reason: tokenValidation.reason, requestId } }; + } - const nonce = deliveryContext.nonce; - const evaluation = envelope.mode === MODE.EVALUATION; + let payload; + try { + payload = JSON.parse(await request.text()); + } catch (parseError) { + error(`body is not JSON: ${parseError.message}`); + return { status: 400, jsonBody: { error: 'bad_request', reason: 'invalid JSON body', requestId } }; + } - // Respect ttlSeconds: don't start a live delivery for an already-expired passcode (contract §7). - if (!evaluation && typeof envelope.ttlSeconds === 'number' && envelope.ttlSeconds <= 0) { - context.log(`[EXPIRED] requestId=${requestId} correlationId=${correlationId} ttl=${envelope.ttlSeconds}`); - return { status: 400, jsonBody: { error: 'request_expired', correlationId, requestId } }; - } + const parsed = parseEnvelope(payload); + if (parsed.error) { + error(`envelope rejected: ${parsed.error}`); + return { status: 400, jsonBody: { error: 'bad_request', reason: parsed.error, requestId } }; + } + envelope = parsed.envelope; - context.log( - `[SENDOTP] requestId=${requestId} caller=${callerOid || 'n/a'} type=${envelope.type || 'n/a'} ` + - `tenant=${envelope.tenantId || 'n/a'} correlationId=${correlationId} channel=${envelope.channel} mode=${envelope.mode} ` + - `ttl=${envelope.ttlSeconds ?? 'n/a'} phone=present message=present risk=${deliveryContext.riskContext ? 'present' : 'absent'}` - ); + log('type', envelope.type); + log('tenantId', envelope.tenantId); + log('correlationId', envelope.correlationId); + log('channel', envelope.channel); + log('mode', envelope.mode); + log('ttlSeconds', envelope.ttlSeconds); - const dispatch = contextToDispatch(deliveryContext, envelope, clientRequestId); + const correlationId = envelope.correlationId || headerCorrelationId || requestId; + + // Surfaced rather than swallowed: the passcode expires before it can be used, so delivering + // it would only produce a failed sign-in and a support call. + if (envelope.ttlSeconds !== undefined && envelope.ttlSeconds <= 0) { + warn(`ttlSeconds is ${envelope.ttlSeconds}; the passcode has expired.`); + } + + let header; + let delivery; + try { + ({ header, context: delivery } = await decryptDeliveryContext( + envelope.encryptedDeliveryContext, { config })); + } catch (decryptError) { + error(`decryption failed: ${decryptError.message}`); + return { status: 400, jsonBody: { error: 'decryption_failed', correlationId, requestId } }; + } + + const kidMatches = !config.expectedKeyId || header.kid === config.expectedKeyId; + log('kid', `${header.kid}${kidMatches ? '' : ' (DOES NOT match EPP_ENCRYPTION_KEY_ID)'}`); + log('alg / enc', `${header.alg} / ${header.enc}`); + log('decrypted', 'OK'); + log('nonce', delivery.nonce); + + if (config.logPlaintext) { + // DIAGNOSTICS ONLY — writes the phone number and passcode to the log. + log('phoneNumber', delivery.phoneNumber); + log('extension', delivery.extension || '(none)'); + log('locale', delivery.locale); + log('message', delivery.message); + log('passcode', extractPasscode(delivery.message) || '(none found)'); + log('riskContext', delivery.riskContext ? JSON.stringify(delivery.riskContext) : '(none)'); + } else { + context.log(`${TAG} plaintext suppressed (EPP_LOG_PLAINTEXT=false)`); + } + + if (!delivery.nonce || !delivery.phoneNumber || !delivery.message) { + error('delivery context is incomplete (nonce/phoneNumber/message)'); + return { status: 400, jsonBody: { error: 'bad_request', reason: 'incomplete delivery context', correlationId, requestId } }; + } + + const evaluation = envelope.mode === MODE.EVALUATION; + const dispatch = contextToDispatch(delivery, envelope, clientRequestId); + + deliverInBackground(dispatch, envelope, evaluation, config, context, requestId); + + // Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery + // and Microsoft re-sends over its own telephony, so the user gets the code twice. + const body = { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }; + + log('responding', `200, nonce echoed, ${Date.now() - started} ms`); + context.log(`${TAG} ======== done ========`); + + return { status: 200, jsonBody: body }; + } catch (unhandled) { + // Verbose on purpose: this endpoint exists to diagnose onboarding. + error(`FAILED after ${Date.now() - started} ms: ${unhandled.message}`); + if (unhandled.cause) { + error(`caused by: ${unhandled.cause.message || unhandled.cause}`); + } + context.log(`${TAG} ======== failed ========`); - try { - const { httpStatus } = await dispatchOtp(dispatch, { - tenantId: envelope.tenantId, - requestProvider: undefined, - shutter: evaluation, - context, - requestId, - }); - // Contract: acceptance is 202 Accepted (async delivery); the engine signals acceptance as 200. - const accepted = httpStatus === 200; return { - status: accepted ? 202 : httpStatus, - jsonBody: { nonce, correlationId, providerStatus: accepted ? 'accepted' : 'failed' }, + status: 500, + jsonBody: { + error: 'delivery_failed', + detail: unhandled.message, + correlationId: envelope && envelope.correlationId, + }, }; - } catch (error) { - context.log(`[EXCEPTION] requestId=${requestId} error=${error.message} stack=${error.stack}`); - return { status: 500, jsonBody: { nonce, correlationId, providerStatus: 'failed' } }; } }, }); + +module.exports = { whenDelivered }; diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js new file mode 100644 index 0000000..50edd35 --- /dev/null +++ b/javascript/src/functions/config.js @@ -0,0 +1,44 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// + +'use strict'; + +// Every setting the endpoint reads, in one place. Names are the EPP_* app settings provisioned on the +// Function App; EPP_DECRYPTION_KEY_PEM is a Key Vault reference, so the runtime only ever sees the +// resolved PEM and never calls Key Vault itself. + +const DEFAULT_TIMEOUT_MS = 1500; + +// Read per call so a settings change (or a test) is picked up without a cold start. +function readConfig(env = process.env) { + return { + decryptionKeyPem: env.EPP_DECRYPTION_KEY_PEM || '', + expectedKeyId: env.EPP_ENCRYPTION_KEY_ID || '', + expectedAudience: env.EPP_EXPECTED_AUDIENCE || '', + expectedClientId: env.EPP_EXPECTED_CLIENT_ID || '', + tenantId: env.EPP_TENANT_ID || '', + // PII in the log. Diagnostics only, and must stay false in production. + logPlaintext: String(env.EPP_LOG_PLAINTEXT || '').toLowerCase() === 'true', + requireAuth: String(env.EPP_REQUIRE_AUTH || '').toLowerCase() === 'true', + provider: { + name: env.EPP_PROVIDER_NAME || '', + endpoint: env.EPP_PROVIDER_ENDPOINT || '', + accountName: env.EPP_PROVIDER_ACCOUNT_NAME || '', + timeoutMs: Number(env.EPP_PROVIDER_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS, + }, + }; +} + +// Reported, never thrown: a missing provider setting still lets the delivery prove decryption. +function missingSettings(config) { + const absent = []; + if (!config.decryptionKeyPem) absent.push('EPP_DECRYPTION_KEY_PEM'); + if (!config.provider.name) absent.push('EPP_PROVIDER_NAME'); + if (!config.provider.endpoint) absent.push('EPP_PROVIDER_ENDPOINT'); + if (config.requireAuth && !config.expectedAudience) absent.push('EPP_EXPECTED_AUDIENCE'); + if (config.requireAuth && !config.tenantId) absent.push('EPP_TENANT_ID'); + return absent; +} + +module.exports = { readConfig, missingSettings }; diff --git a/javascript/src/functions/cyot.js b/javascript/src/functions/cyot.js index 63c69e8..fbe260e 100644 --- a/javascript/src/functions/cyot.js +++ b/javascript/src/functions/cyot.js @@ -11,7 +11,7 @@ const crypto = require('crypto'); const { compactDecrypt } = require('jose'); -const { resolveSecretValue } = require('./dispatch'); +const { readConfig } = require('./config'); // CyotChannel: 1=Sms, 2=Voice (0=Undefined). CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); @@ -67,53 +67,67 @@ function assertWellFormedJwe(compactJwe) { } } -// Reads the `kid` from the JWE protected (first) segment without decrypting. -function readKid(compactJwe) { +// Reads the JWE protected (first) segment without decrypting, so kid/alg/enc can be logged. +function readProtectedHeader(compactJwe) { const protectedSegment = String(compactJwe).split('.')[0] || ''; - const header = JSON.parse(Buffer.from(protectedSegment, 'base64url').toString('utf8')); - return header.kid || null; + return JSON.parse(Buffer.from(protectedSegment, 'base64url').toString('utf8')); } -// Default key source: an inline PEM (local/dev) or a Key Vault secret (deployed). The `kid` selects the -// secret name when JWE_PRIVATE_KEY_SECRET is not set. -async function resolvePrivateKeyPem(kid, env) { - if (env.CYOT_JWE_PRIVATE_KEY_PEM) { - return env.CYOT_JWE_PRIVATE_KEY_PEM; +// The key is imported once and reused: doing it per delivery would add an RSA import inside the +// response budget and turn a bad key into a failure on every call instead of one obvious first failure. +let cachedKey; +let cachedKeyPem; + +// The setup script stores the key as base64 over the PEM so its newlines survive being carried as a +// secret and then as an app setting, so accept either form. +function normalizePem(value) { + const text = String(value || ''); + if (text.includes('-----BEGIN')) return text; + return Buffer.from(text, 'base64').toString('utf8'); +} + +function loadPrivateKey(pem) { + if (!pem) { + throw new Error('private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)'); } - const secretName = env.JWE_PRIVATE_KEY_SECRET || kid; - if (!secretName) { - return ''; + if (cachedKey && cachedKeyPem === pem) { + return cachedKey; } - return resolveSecretValue(secretName); + cachedKey = crypto.createPrivateKey(normalizePem(pem)); + cachedKeyPem = pem; + return cachedKey; } -// Decrypts the JWE compact serialization to the CyotDeliveryContext. `keyProvider(kid)` is injectable -// so tests can supply a local key instead of Key Vault. +// Decrypts the JWE compact serialization. Returns the protected header (for kid/alg logging) alongside +// the CyotDeliveryContext. `keyProvider(kid)` is injectable so tests can supply a local key. async function decryptDeliveryContext(compactJwe, options = {}) { - const env = options.env || process.env; + const config = options.config || readConfig(options.env || process.env); assertWellFormedJwe(compactJwe); - const keyProvider = options.keyProvider || ((kid) => resolvePrivateKeyPem(kid, env)); - const kid = readKid(compactJwe); - const pem = await keyProvider(kid); - if (!pem) { - throw new Error('private key unavailable'); - } - const privateKey = crypto.createPrivateKey(pem); + const header = readProtectedHeader(compactJwe); + const pem = options.keyProvider ? await options.keyProvider(header.kid) : config.decryptionKeyPem; + const privateKey = loadPrivateKey(pem); // Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). const { plaintext } = await compactDecrypt(compactJwe, privateKey, { keyManagementAlgorithms: ['RSA-OAEP-256'], contentEncryptionAlgorithms: ['A256GCM'], }); - return JSON.parse(Buffer.from(plaintext).toString('utf8')); + return { header, context: JSON.parse(Buffer.from(plaintext).toString('utf8')) }; } // Maps the decrypted context + envelope onto the engine's dispatch shape. The message is pre-rendered // (already contains the passcode), so there is no separate code — the fields mirror CyotDeliveryContext. +// Voice: left alone, a TTS engine reads 641895 as "six hundred forty-one thousand eight hundred +// ninety-five", which no user can type. Spacing the digits makes it read them one at a time. +function spacePasscodeForVoice(message) { + return String(message || '').replace(/\b\d{4,8}\b/, (digits) => digits.split('').join(' ')); +} + function contextToDispatch(context, envelope, messageId) { + const channel = CHANNEL_BY_CODE[envelope.channel]; return { destination: context.phoneNumber, - message: context.message, - channel: CHANNEL_BY_CODE[envelope.channel], + message: channel === 'voice' ? spacePasscodeForVoice(context.message) : context.message, + channel, messageId, correlationId: envelope.correlationId, locale: context.locale || undefined, @@ -122,7 +136,8 @@ function contextToDispatch(context, envelope, messageId) { module.exports = { parseEnvelope, - readKid, + readProtectedHeader, + loadPrivateKey, decryptDeliveryContext, contextToDispatch, CHANNEL_BY_CODE, diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index 0f85af1..c03a228 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -95,7 +95,7 @@ function getProvider(providerId) { // The request's Provider, else the deployment's DEFAULT_PROVIDER (set by UX at provisioning). One // provider is active per deployment — selection is config, not routing the endpoint performs. function resolveProvider(requestProvider) { - const providerId = (requestProvider || process.env.DEFAULT_PROVIDER || '').toLowerCase(); + const providerId = (requestProvider || process.env.EPP_PROVIDER_NAME || '').toLowerCase(); return getProvider(providerId); } @@ -144,7 +144,7 @@ async function warmUpSecretCache() { if (!process.env.KEY_VAULT_URL) { return; } - const providerId = (process.env.DEFAULT_PROVIDER || '').toLowerCase(); + const providerId = (process.env.EPP_PROVIDER_NAME || '').toLowerCase(); const providerEntry = providerId && getProvider(providerId); const keyVaultSecretName = providerEntry && providerEntry.manifest.auth && providerEntry.manifest.auth.keyVaultSecretName; if (!keyVaultSecretName) { @@ -181,16 +181,10 @@ async function resolveProviderCredential(authConfiguration = {}, options = {}) { // ─── Endpoint & outcome mapping ────────────────────────────────────────────────── -// Base URL from app settings (UX-provisioned): _ENDPOINT_EUDB when EUDB=true, else _ENDPOINT. -// Endpoints are deployment config, never in the manifest. Undefined if unset → fails the send closed. -function resolveEndpointBaseUrl(manifest, environmentVariables) { - const idUpper = manifest.id.toUpperCase(); - const useEudb = String(environmentVariables.EUDB || '').toLowerCase() === 'true'; - const eudbEndpoint = environmentVariables[`${idUpper}_ENDPOINT_EUDB`]; - if (useEudb && eudbEndpoint) { - return eudbEndpoint; - } - return environmentVariables[`${idUpper}_ENDPOINT`]; +// Base URL from app settings (UX-provisioned): one provider is active per deployment, so the endpoint +// is a single EPP_PROVIDER_ENDPOINT rather than a per-provider key. +function resolveEndpointBaseUrl(environmentVariables) { + return environmentVariables.EPP_PROVIDER_ENDPOINT; } // Translates the provider's parsed status into a normalized outcome. A recognized status wins; an @@ -285,7 +279,7 @@ async function sendViaProvider(providerEntry, dispatch, options) { return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; } - const endpointBaseUrl = resolveEndpointBaseUrl(manifest, process.env); + const endpointBaseUrl = resolveEndpointBaseUrl(process.env); if (!endpointBaseUrl) { writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} endpoint not configured`); return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider endpoint not configured', dispatch, requestId) }; @@ -310,8 +304,8 @@ async function sendViaProvider(providerEntry, dispatch, options) { }; } - // Endpoint timeout is a provisioned app setting (UX sets ENDPOINT_TIMEOUT_MS). - const timeoutMilliseconds = Number(process.env.ENDPOINT_TIMEOUT_MS) || DEFAULTS.ENDPOINT_TIMEOUT_MILLISECONDS; + // Endpoint timeout is a provisioned app setting (EPP_PROVIDER_TIMEOUT_MS). + const timeoutMilliseconds = Number(process.env.EPP_PROVIDER_TIMEOUT_MS) || DEFAULTS.ENDPOINT_TIMEOUT_MILLISECONDS; let providerResponse; try { providerResponse = await fetchWithTimeout(providerRequest, timeoutMilliseconds); diff --git a/javascript/src/functions/providers/infobip.js b/javascript/src/functions/providers/infobip.js index d4f91dc..468d646 100644 --- a/javascript/src/functions/providers/infobip.js +++ b/javascript/src/functions/providers/infobip.js @@ -24,7 +24,7 @@ const manifest = { function buildRequest({ channel, endpoint, dispatch, credential, env }) { const base = endpoint; - const senderId = env.INFOBIP_SENDER_ID || 'Verify'; + const senderId = env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify'; const authorization = credential.mode === 'oauth2' ? `Bearer ${credential.token}` : `App ${credential.secret}`; const headers = { Authorization: authorization, @@ -35,7 +35,7 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { if (channel === 'voice') { const body = { messages: [{ - from: env.INFOBIP_VOICE_FROM || senderId, + from: senderId, destinations: [{ to: dispatch.destination, messageId: dispatch.correlationId || dispatch.messageId }], text: dispatch.message, language: dispatch.locale || 'en', diff --git a/javascript/src/functions/providers/sinch.js b/javascript/src/functions/providers/sinch.js index 8fec32f..901b500 100644 --- a/javascript/src/functions/providers/sinch.js +++ b/javascript/src/functions/providers/sinch.js @@ -47,7 +47,7 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { const smsBase = endpoint; const servicePlanId = env.SINCH_SERVICE_PLAN_ID || ''; const body = { - from: env.SINCH_SENDER_ID || 'Verify', + from: env.EPP_PROVIDER_ACCOUNT_NAME || 'Verify', to: [dispatch.destination], body: dispatch.message, client_reference: dispatch.correlationId || dispatch.messageId, diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index 9cac4de..c82e0db 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -48,10 +48,13 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { clientReference: dispatch.correlationId || dispatch.messageId, }; // Sender: a provisioned source endpoint (endpoints:[{type,id}]) is what Soprano accepts; free-text source is a fallback. - if (env.SOPRANO_SOURCE_ID) { - body.endpoints = [{ type: Number(env.SOPRANO_SOURCE_TYPE || 1), id: Number(env.SOPRANO_SOURCE_ID) }]; - } else if (env.SOPRANO_SENDER_ID) { - body.source = env.SOPRANO_SENDER_ID; + // Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A + // non-numeric account name is sent as a free-text source instead. + const account = env.EPP_PROVIDER_ACCOUNT_NAME; + if (account && /^\d+$/.test(account)) { + body.endpoints = [{ type: Number(env.SOPRANO_SOURCE_TYPE || 1), id: Number(account) }]; + } else if (account) { + body.source = account; } // Voice: Soprano speaks the fully-rendered message via text-to-speech. `language` must be a full // Nexmo voice code (e.g. en-US), not a bare `en`. diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index 4552b29..c8ee5b1 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -54,7 +54,7 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { params = new URLSearchParams({ phone_number: dispatch.destination, message: dispatch.message, - sender_id: env.TELESIGN_SENDER_ID || '', + sender_id: env.EPP_PROVIDER_ACCOUNT_NAME || '', message_type: 'OTP', external_id: dispatch.correlationId || dispatch.messageId, is_primary: 'true', diff --git a/javascript/src/functions/security.js b/javascript/src/functions/security.js index e6f63eb..807a35e 100644 --- a/javascript/src/functions/security.js +++ b/javascript/src/functions/security.js @@ -23,14 +23,14 @@ function getJwks(issuerTenantId) { // Validates the inbound bearer token when REQUIRE_AUTH is enabled (issuer, audience, expiry, RS256). async function validateToken(request, context, requestId) { - if (String(process.env.REQUIRE_AUTH || 'false').toLowerCase() !== 'true') { + if (String(process.env.EPP_REQUIRE_AUTH || 'false').toLowerCase() !== 'true') { return { ok: true, skipped: true }; } - const audience = process.env.EXPECTED_AUDIENCE; - const tenantId = process.env.ISSUER_TENANT_ID; + const audience = process.env.EPP_EXPECTED_AUDIENCE; + const tenantId = process.env.EPP_TENANT_ID; if (!audience || !tenantId) { - return { ok: false, reason: 'REQUIRE_AUTH is set but EXPECTED_AUDIENCE / ISSUER_TENANT_ID are missing' }; + return { ok: false, reason: 'EPP_REQUIRE_AUTH is set but EPP_EXPECTED_AUDIENCE / EPP_TENANT_ID are missing' }; } const authorizationHeader = (request.headers.get('authorization') || '').trim(); @@ -41,11 +41,14 @@ async function validateToken(request, context, requestId) { try { const { jwtVerify } = require('jose'); - // Accept both the v2 (login.microsoftonline.com/.../v2.0) and v1 (sts.windows.net/.../) issuers. - const issuers = [ - `https://login.microsoftonline.com/${tenantId}/v2.0`, - `https://sts.windows.net/${tenantId}/`, - ]; + // Accept both the v2 (login.microsoftonline.com/.../v2.0) and v1 (sts.windows.net/.../) issuers, + // unless EPP_EXPECTED_ISSUER pins one explicitly. + const issuers = process.env.EPP_EXPECTED_ISSUER + ? [process.env.EPP_EXPECTED_ISSUER] + : [ + `https://login.microsoftonline.com/${tenantId}/v2.0`, + `https://sts.windows.net/${tenantId}/`, + ]; await jwtVerify(bearerToken, getJwks(tenantId), { audience, issuer: issuers, diff --git a/javascript/test/auth.test.js b/javascript/test/auth.test.js index 6639658..f885648 100644 --- a/javascript/test/auth.test.js +++ b/javascript/test/auth.test.js @@ -8,9 +8,9 @@ const ctx = { log() {} }; const reqWith = (headers = {}) => ({ headers: { get: (k) => headers[k.toLowerCase()] || null } }); afterEach(() => { - delete process.env.REQUIRE_AUTH; - delete process.env.EXPECTED_AUDIENCE; - delete process.env.ISSUER_TENANT_ID; + delete process.env.EPP_REQUIRE_AUTH; + delete process.env.EPP_EXPECTED_AUDIENCE; + delete process.env.EPP_TENANT_ID; }); test('skips validation when REQUIRE_AUTH is not true', async () => { @@ -19,26 +19,26 @@ test('skips validation when REQUIRE_AUTH is not true', async () => { assert.equal(r.skipped, true); }); -test('fails when REQUIRE_AUTH=true but audience/tenant are missing', async () => { - process.env.REQUIRE_AUTH = 'true'; +test('fails when EPP_REQUIRE_AUTH=true but audience/tenant are missing', async () => { + process.env.EPP_REQUIRE_AUTH = 'true'; const r = await validateToken(reqWith(), ctx, 'r'); assert.equal(r.ok, false); - assert.match(r.reason, /EXPECTED_AUDIENCE|ISSUER_TENANT_ID/); + assert.match(r.reason, /EPP_EXPECTED_AUDIENCE|EPP_TENANT_ID/); }); test('fails when the bearer token is missing', async () => { - process.env.REQUIRE_AUTH = 'true'; - process.env.EXPECTED_AUDIENCE = 'aud'; - process.env.ISSUER_TENANT_ID = 'tid'; + process.env.EPP_REQUIRE_AUTH = 'true'; + process.env.EPP_EXPECTED_AUDIENCE = 'aud'; + process.env.EPP_TENANT_ID = 'tid'; const r = await validateToken(reqWith(), ctx, 'r'); assert.equal(r.ok, false); assert.equal(r.reason, 'missing bearer token'); }); test('fails (generic reason) on an invalid token', async () => { - process.env.REQUIRE_AUTH = 'true'; - process.env.EXPECTED_AUDIENCE = 'aud'; - process.env.ISSUER_TENANT_ID = 'tid'; + process.env.EPP_REQUIRE_AUTH = 'true'; + process.env.EPP_EXPECTED_AUDIENCE = 'aud'; + process.env.EPP_TENANT_ID = 'tid'; const r = await validateToken(reqWith({ authorization: 'Bearer not-a-jwt' }), ctx, 'r'); assert.equal(r.ok, false); assert.equal(r.reason, 'token validation failed'); diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index f8e4578..05db40f 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -8,10 +8,7 @@ const assert = require('node:assert'); // Non-secret provider config (app settings, not secrets) — set before requiring the modules. process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; process.env.SINCH_SERVICE_PLAN_ID = 'sp'; -process.env.INFOBIP_ENDPOINT = 'https://api.infobip.com'; -process.env.TELESIGN_ENDPOINT = 'https://rest-api.telesign.com'; -process.env.SINCH_ENDPOINT = 'https://sms.api.sinch.com'; -process.env.SOPRANO_ENDPOINT = 'https://mems.example.com/cgpapi'; +process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; // Provider secrets come from Key Vault via managed identity in production; mock getSecret here. const providerSecrets = { diff --git a/javascript/test/security.test.js b/javascript/test/security.test.js index 55a9ead..a69aa66 100644 --- a/javascript/test/security.test.js +++ b/javascript/test/security.test.js @@ -8,7 +8,7 @@ const assert = require('node:assert'); // The provider secret comes from Key Vault via managed identity in production; mock getSecret here. process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; -process.env.INFOBIP_ENDPOINT = 'https://api.infobip.com'; +process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; const { SecretClient } = require('@azure/keyvault-secrets'); mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'ib' })); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 1e8eb78..70bb92c 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -3,7 +3,7 @@ // Tests for the SendOtp HTTP handler — the SAS → CYOT envelope: validation, JWE decryption round-trip, // the happy path (nonce echo), Evaluation mode, and auth rejection. Handlers are captured by stubbing // @azure/functions; the JWE is encrypted here with a throwaway RSA key that the handler decrypts via -// CYOT_JWE_PRIVATE_KEY_PEM. +// EPP_DECRYPTION_KEY_PEM. const { test, mock } = require('node:test'); const assert = require('node:assert'); @@ -13,10 +13,10 @@ const { CompactEncrypt } = require('jose'); // Throwaway RSA keypair: the handler decrypts with the private PEM from the environment. const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); -process.env.CYOT_JWE_PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); +process.env.EPP_DECRYPTION_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; -process.env.INFOBIP_ENDPOINT = 'https://api.infobip.com'; -process.env.DEFAULT_PROVIDER = 'infobip'; +process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; +process.env.EPP_PROVIDER_NAME = 'infobip'; const { SecretClient } = require('@azure/keyvault-secrets'); mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'ib' })); @@ -33,6 +33,9 @@ Module._load = function (request, parent, isMain) { require('../src/functions/SendOtp'); Module._load = originalLoad; +// The handler answers before the provider call finishes, so tests await the send it kicked off. +const { whenDelivered } = require('../src/functions/SendOtp'); + const ctx = { log() {} }; const makeReq = (body, headers = {}) => ({ @@ -114,29 +117,34 @@ test('SendOtp: incomplete context (no phoneNumber) -> 400', async () => { assert.match(r.jsonBody.reason, /incomplete/); }); -test('SendOtp: Live with ttlSeconds <= 0 -> 400 request_expired', async () => { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ ttlSeconds: 0 })), ctx); - assert.equal(r.status, 400); - assert.equal(r.jsonBody.error, 'request_expired'); +test('SendOtp: Live with ttlSeconds <= 0 still delivers, but warns', async () => { + const lines = []; + const warnCtx = { log: (m) => lines.push(String(m)), warn: (m) => lines.push(String(m)), error: () => {} }; + const r = await handlers.SendOtp(makeReq(await makeEnvelope({ ttlSeconds: 0 })), warnCtx); + await whenDelivered(); + assert.equal(r.status, 200); + assert.ok(lines.some((l) => /has expired/.test(l)), 'expected an expiry warning'); }); -test('SendOtp: valid Live envelope -> 202 accepted, nonce echoed, sent over https', async () => { +test('SendOtp: valid Live envelope -> 200 accepted, nonce echoed, sent over https', async () => { sent = undefined; const r = await handlers.SendOtp(makeReq(await makeEnvelope()), ctx); - assert.equal(r.status, 202); + await whenDelivered(); + assert.equal(r.status, 200); assert.equal(r.jsonBody.providerStatus, 'accepted'); assert.equal(r.jsonBody.nonce, 'nonce-abc'); assert.equal(r.jsonBody.correlationId, 'corr-1'); assert.match(sent.url, /^https:\/\//); }); -test('SendOtp: Evaluation mode -> 202 nonce echoed, nothing sent', async () => { +test('SendOtp: Evaluation mode -> 200 nonce echoed, nothing sent', async () => { let calls = 0; const original = global.fetch; global.fetch = async (...a) => { calls++; return original(...a); }; try { const r = await handlers.SendOtp(makeReq(await makeEnvelope({ mode: 2 })), ctx); - assert.equal(r.status, 202); + await whenDelivered(); + assert.equal(r.status, 200); assert.equal(r.jsonBody.nonce, 'nonce-abc'); assert.equal(calls, 0); } finally { @@ -145,11 +153,11 @@ test('SendOtp: Evaluation mode -> 202 nonce echoed, nothing sent', async () => { }); test('SendOtp: REQUIRE_AUTH enabled but misconfigured -> 401', async () => { - process.env.REQUIRE_AUTH = 'true'; + process.env.EPP_REQUIRE_AUTH = 'true'; try { const r = await handlers.SendOtp(makeReq(await makeEnvelope(), { authorization: 'Bearer abc' }), ctx); assert.equal(r.status, 401); } finally { - delete process.env.REQUIRE_AUTH; + delete process.env.EPP_REQUIRE_AUTH; } }); diff --git a/python/README.md b/python/README.md index d79f7ba..58b2182 100644 --- a/python/README.md +++ b/python/README.md @@ -14,12 +14,12 @@ python/ ├─ src/ │ ├─ cyot.py # envelope parse/validate + JWE decrypt + context → dispatch │ ├─ dispatch.py # resolve provider → credential → endpoint → send → outcome -│ ├─ registry.py # adapter registry + DEFAULT_PROVIDER resolution +│ ├─ registry.py # adapter registry + EPP_PROVIDER_NAME resolution │ ├─ providers/*.py # infobip, telesign, soprano, sinch (manifest + build/parse) │ ├─ secrets.py # Key Vault via managed identity (cached) │ ├─ outcome.py # status → outcome → HTTP status │ ├─ models.py # DispatchRequest + outcome constants -│ └─ security.py # Entra JWT validation when REQUIRE_AUTH=true +│ └─ security.py # Entra JWT validation when EPP_REQUIRE_AUTH=true └─ tests/ # pytest conformance tests ``` diff --git a/python/function_app.py b/python/function_app.py index 466494a..1e8d930 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -1,10 +1,17 @@ -"""CYOT OTP Function — Python v2 model. HTTP trigger: POST /api/SendOtp — the SAS → CYOT delivery -endpoint. Validates the token, parses the cleartext routing envelope, decrypts the JWE delivery context -(PII lives there), dispatches, and echoes the nonce to prove decryption. -Privacy: phone and OTP code are never logged or returned in the body.""" +"""External Phone Provider OTP Function — Python v2 model. HTTP trigger: POST /api/SendOtp — the SAS → +External Phone Provider delivery endpoint. Validates the caller, parses the cleartext routing envelope, +decrypts the JWE delivery context (PII lives there), dispatches, and echoes the nonce to prove decryption. + +Every line starts with [EPP], so a whole delivery can be pulled out of a noisy log with one filter: + Application Insights : traces | where message startswith "[EPP]" | order by timestamp asc +""" +import base64 import json import logging import os +import re +import threading +import time import uuid import azure.functions as func @@ -25,77 +32,154 @@ from src.secrets import SecretResolver from src.security import validate_token +TAG = "[EPP]" + app = func.FunctionApp() _registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) _secrets = SecretResolver() _engine = DispatchEngine(_registry, _secrets) -_key_provider = make_key_provider(os.environ, _secrets) +_key_provider = make_key_provider(os.environ) def _json(status_code, body): return func.HttpResponse(json.dumps(body), status_code=status_code, mimetype="application/json") -@app.route(route="SendOtp", methods=["POST"], auth_level=func.AuthLevel.ANONYMOUS) -def send_otp(req: func.HttpRequest) -> func.HttpResponse: - request_id = uuid.uuid4().hex - client_request_id = req.headers.get("x-ms-client-request-id") or request_id - header_correlation_id = req.headers.get("x-ms-correlation-id") +def _log(label, value): + logging.info("%s %-18s: %s", TAG, label, value) - auth_ok, reason, caller_object_id = validate_token(req.headers.get("Authorization")) - if not auth_ok: - logging.warning("[AUTH_ERROR] requestId=%s reason=%s", request_id, reason) - return _json(401, {"error": "unauthorized", "reason": reason, "requestId": request_id}) +def _read_caller_app_id(req): + """Easy Auth has already validated the token; this only records which identity actually arrived.""" + encoded = req.headers.get("x-ms-client-principal") + if not encoded: + return None try: - payload = req.get_json() - except ValueError: - logging.warning("[ERROR] requestId=%s invalid JSON body", request_id) - return _json(400, {"error": "bad_request", "reason": "invalid JSON body", "requestId": request_id}) + principal = json.loads(base64.b64decode(encoded).decode("utf-8")) + for claim in principal.get("claims") or []: + if claim.get("typ") in ("appid", "azp"): + return claim.get("val") + except Exception: + return None + return None - envelope, error = parse_envelope(payload) - if error: - logging.warning("[VALIDATION_ERROR] requestId=%s %s", request_id, error) - return _json(400, {"error": "bad_request", "reason": error, "requestId": request_id}) - correlation_id = envelope["correlation_id"] or header_correlation_id or request_id - - try: - delivery = decrypt_delivery_context(envelope["encrypted_delivery_context"], _key_provider) - except Exception as err: - logging.warning("[DECRYPT_ERROR] requestId=%s correlationId=%s reason=%s", request_id, correlation_id, err) - return _json(400, {"error": "decryption_failed", "correlationId": correlation_id, "requestId": request_id}) +def _extract_passcode(message): + """Lifts the passcode out of the rendered sentence, purely so it is easy to eyeball in the log.""" + match = re.search(r"\b\d{4,8}\b", message or "") + return match.group(0) if match else None - if not delivery or not delivery.get("nonce") or not delivery.get("phoneNumber") or not delivery.get("message"): - logging.warning("[VALIDATION_ERROR] requestId=%s correlationId=%s incomplete delivery context", request_id, correlation_id) - return _json(400, {"error": "bad_request", "reason": "incomplete delivery context", "correlationId": correlation_id, "requestId": request_id}) - nonce = delivery["nonce"] - evaluation = envelope["mode"] == MODE_EVALUATION - - # Respect ttlSeconds: don't start a live delivery for an already-expired passcode (contract §7). - ttl_seconds = envelope["ttl_seconds"] - if not evaluation and isinstance(ttl_seconds, (int, float)) and not isinstance(ttl_seconds, bool) and ttl_seconds <= 0: - logging.warning("[EXPIRED] requestId=%s correlationId=%s ttl=%s", request_id, correlation_id, ttl_seconds) - return _json(400, {"error": "request_expired", "correlationId": correlation_id, "requestId": request_id}) - - logging.info( - "[SENDOTP] requestId=%s caller=%s type=%s tenant=%s correlationId=%s channel=%s mode=%s phone=present message=present risk=%s", - request_id, caller_object_id or "n/a", envelope["type"] or "n/a", envelope["tenant_id"] or "n/a", - correlation_id, envelope["channel"], envelope["mode"], "present" if delivery.get("riskContext") else "absent", - ) +@app.route(route="SendOtp", methods=["POST"], auth_level=func.AuthLevel.ANONYMOUS) +def send_otp(req: func.HttpRequest) -> func.HttpResponse: + started = time.time() + request_id = uuid.uuid4().hex + client_request_id = req.headers.get("x-ms-client-request-id") or request_id + header_correlation_id = req.headers.get("x-ms-correlation-id") + log_plaintext = (os.environ.get("EPP_LOG_PLAINTEXT") or "").lower() == "true" + expected_key_id = os.environ.get("EPP_ENCRYPTION_KEY_ID") + expected_client_id = os.environ.get("EPP_EXPECTED_CLIENT_ID") - dispatch = context_to_dispatch(delivery, envelope, client_request_id) + logging.info("%s ======== delivery received ========", TAG) + _log("invocation", request_id) + correlation_id = None try: - status_code, _ = _engine.dispatch(dispatch, None, evaluation, request_id, logging) - # Contract: acceptance is 202 Accepted (async delivery); the engine signals acceptance as 200. - accepted = status_code == 200 - return _json( - 202 if accepted else status_code, - {"nonce": nonce, "correlationId": correlation_id, "providerStatus": "accepted" if accepted else "failed"}, - ) + caller_app_id = _read_caller_app_id(req) + _log("caller appid", caller_app_id or "none (Easy Auth off, or called directly)") + + if caller_app_id and expected_client_id and caller_app_id != expected_client_id: + logging.error("%s caller %s is not %s. Easy Auth allowedApplications is not doing its job.", + TAG, caller_app_id, expected_client_id) + return _json(403, {"error": "unexpected_caller"}) + + auth_ok, reason, _caller_object_id = validate_token(req.headers.get("Authorization")) + if not auth_ok: + logging.error("%s token rejected: %s", TAG, reason) + return _json(401, {"error": "unauthorized", "reason": reason, "requestId": request_id}) + + try: + payload = req.get_json() + except ValueError: + logging.error("%s body is not JSON", TAG) + return _json(400, {"error": "bad_request", "reason": "invalid JSON body", "requestId": request_id}) + + envelope, error = parse_envelope(payload) + if error: + logging.error("%s envelope rejected: %s", TAG, error) + return _json(400, {"error": "bad_request", "reason": error, "requestId": request_id}) + + _log("type", envelope["type"]) + _log("tenantId", envelope["tenant_id"]) + _log("correlationId", envelope["correlation_id"]) + _log("channel", envelope["channel"]) + _log("mode", envelope["mode"]) + _log("ttlSeconds", envelope["ttl_seconds"]) + + correlation_id = envelope["correlation_id"] or header_correlation_id or request_id + + # Surfaced rather than swallowed: the passcode expires before it can be used, so delivering it + # would only produce a failed sign-in and a support call. + ttl_seconds = envelope["ttl_seconds"] + if isinstance(ttl_seconds, (int, float)) and not isinstance(ttl_seconds, bool) and ttl_seconds <= 0: + logging.warning("%s ttlSeconds is %s; the passcode has expired.", TAG, ttl_seconds) + + try: + header, delivery = decrypt_delivery_context(envelope["encrypted_delivery_context"], _key_provider) + except Exception as err: + logging.error("%s decryption failed: %s", TAG, err) + return _json(400, {"error": "decryption_failed", "correlationId": correlation_id, "requestId": request_id}) + + kid = header.get("kid") + kid_matches = not expected_key_id or kid == expected_key_id + _log("kid", f"{kid}{'' if kid_matches else ' (DOES NOT match EPP_ENCRYPTION_KEY_ID)'}") + _log("alg / enc", f"{header.get('alg')} / {header.get('enc')}") + _log("decrypted", "OK") + _log("nonce", delivery.get("nonce")) + + if log_plaintext: + # DIAGNOSTICS ONLY — writes the phone number and passcode to the log. + _log("phoneNumber", delivery.get("phoneNumber")) + _log("extension", delivery.get("extension") or "(none)") + _log("locale", delivery.get("locale")) + _log("message", delivery.get("message")) + _log("passcode", _extract_passcode(delivery.get("message")) or "(none found)") + _log("riskContext", json.dumps(delivery["riskContext"]) if delivery.get("riskContext") else "(none)") + else: + logging.info("%s plaintext suppressed (EPP_LOG_PLAINTEXT=false)", TAG) + + if not delivery.get("nonce") or not delivery.get("phoneNumber") or not delivery.get("message"): + logging.error("%s delivery context is incomplete (nonce/phoneNumber/message)", TAG) + return _json(400, {"error": "bad_request", "reason": "incomplete delivery context", + "correlationId": correlation_id, "requestId": request_id}) + + evaluation = envelope["mode"] == MODE_EVALUATION + dispatch = context_to_dispatch(delivery, envelope, client_request_id) + + # Microsoft allows 3.2 s for the whole call, so the provider is called after the response. + def _deliver(): + try: + status, _body = _engine.dispatch(dispatch, None, evaluation, request_id, logging) + logging.info("%s provider result : httpStatus=%s correlationId=%s", TAG, status, correlation_id) + except Exception as delivery_error: + logging.error("%s provider delivery failed: %s", TAG, delivery_error) + + # daemon so a stalled provider call cannot hold up worker shutdown. + threading.Thread(target=_deliver, name="epp-delivery", daemon=True).start() + + # Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery and + # Microsoft re-sends over its own telephony, so the user gets the code twice. + _log("responding", f"200, nonce echoed, {(time.time() - started) * 1000:.0f} ms") + logging.info("%s ======== done ========", TAG) + + return _json(200, { + "nonce": delivery["nonce"], + "correlationId": correlation_id, + "providerStatus": "accepted", + }) except Exception as error: - logging.error("[EXCEPTION] requestId=%s error=%s", request_id, error) - return _json(500, {"nonce": nonce, "correlationId": correlation_id, "providerStatus": "failed"}) + # Verbose on purpose: this endpoint exists to diagnose onboarding. + logging.error("%s FAILED after %.0f ms: %s", TAG, (time.time() - started) * 1000, error) + logging.info("%s ======== failed ========", TAG) + return _json(500, {"error": "delivery_failed", "detail": str(error), "correlationId": correlation_id}) diff --git a/python/src/cyot.py b/python/src/cyot.py index 22bf603..d02a67f 100644 --- a/python/src/cyot.py +++ b/python/src/cyot.py @@ -4,6 +4,7 @@ and maps the CyotDeliveryContext onto the dispatch engine's request shape.""" import base64 import json +import re from jwcrypto import jwe as jwe_module from jwcrypto import jwk @@ -64,25 +65,18 @@ def parse_envelope(payload): }, None -def read_kid(compact_jwe): - """Reads the `kid` from the JWE protected (first) segment without decrypting.""" +def read_protected_header(compact_jwe): + """Reads the JWE protected (first) segment without decrypting, so kid/alg/enc can be logged.""" header_segment = compact_jwe.split(".")[0] header_segment += "=" * (-len(header_segment) % 4) - header = json.loads(base64.urlsafe_b64decode(header_segment)) - return header.get("kid") - - -def make_key_provider(env, secrets): - """Returns a key_provider(kid) -> PEM. Uses an inline PEM (CYOT_JWE_PRIVATE_KEY_PEM, local/dev) or a - Key Vault secret (name = JWE_PRIVATE_KEY_SECRET, else the `kid`).""" - def key_provider(kid): - pem = env.get("CYOT_JWE_PRIVATE_KEY_PEM") - if pem: - return pem - secret_name = env.get("JWE_PRIVATE_KEY_SECRET") or kid - if not secret_name: - return "" - return secrets.resolve(secret_name) + return json.loads(base64.urlsafe_b64decode(header_segment)) + + +def make_key_provider(env): + """Returns a key_provider(kid) -> PEM. The key comes from EPP_DECRYPTION_KEY_PEM, which is a Key + Vault reference, so the runtime only ever sees the resolved PEM.""" + def key_provider(_kid): + return env.get("EPP_DECRYPTION_KEY_PEM") or "" return key_provider @@ -102,18 +96,46 @@ def _assert_well_formed_jwe(compact_jwe): raise ValueError("malformed JWE: expected five non-empty segments") +# The key is imported once and reused: doing it per delivery would add an RSA import inside the response +# budget and turn a bad key into a failure on every call instead of one obvious first failure. +_key_cache = {} + + +def _normalize_pem(value): + """The setup script stores the key as base64 over the PEM so its newlines survive being carried as a + secret and then as an app setting, so accept either form.""" + text = value if isinstance(value, str) else value.decode("utf-8") + if "-----BEGIN" in text: + return text + return base64.b64decode(text).decode("utf-8") + + +def _load_private_key(pem): + if not pem: + raise ValueError("private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)") + cached = _key_cache.get(pem) + if cached is None: + cached = jwk.JWK.from_pem(_normalize_pem(pem).encode("utf-8")) + _key_cache.clear() + _key_cache[pem] = cached + return cached + + def decrypt_delivery_context(compact_jwe, key_provider): - """key_provider(kid) -> PEM string. Returns the decrypted CyotDeliveryContext dict.""" + """key_provider(kid) -> PEM string. Returns (header, CyotDeliveryContext dict).""" _assert_well_formed_jwe(compact_jwe) - kid = read_kid(compact_jwe) - pem = key_provider(kid) - if not pem: - raise ValueError("private key unavailable") - key = jwk.JWK.from_pem(pem.encode("utf-8") if isinstance(pem, str) else pem) + header = read_protected_header(compact_jwe) + key = _load_private_key(key_provider(header.get("kid"))) # Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). token = jwe_module.JWE(algs=["RSA-OAEP-256", "A256GCM"]) token.deserialize(compact_jwe, key=key) - return json.loads(token.payload.decode("utf-8")) + return header, json.loads(token.payload.decode("utf-8")) + + +def _space_passcode_for_voice(message): + """Voice: left alone, a TTS engine reads 641895 as "six hundred forty-one thousand eight hundred + ninety-five", which no user can type. Spacing the digits makes it read them one at a time.""" + return re.sub(r"\b\d{4,8}\b", lambda m: " ".join(m.group(0)), message or "", count=1) def context_to_dispatch(context, envelope, message_id): @@ -121,7 +143,8 @@ def context_to_dispatch(context, envelope, message_id): pre-rendered (already contains the passcode), so the fields mirror CyotDeliveryContext.""" return DispatchRequest( destination=context.get("phoneNumber"), - message=context.get("message"), + message=(_space_passcode_for_voice(context.get("message")) + if CHANNEL_BY_CODE[envelope["channel"]] == "voice" else context.get("message")), channel=CHANNEL_BY_CODE[envelope["channel"]], message_id=message_id, correlation_id=envelope["correlation_id"], diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 69ef6ab..019ee2a 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -62,7 +62,7 @@ def dispatch(self, dispatch, request_provider, shutter, request_id, log): return 200, {"status": "accepted", "shutterProcessed": True, "provider": provider_id, "channel": channel, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} try: - timeout_ms = int(self.env.get("ENDPOINT_TIMEOUT_MS") or DEFAULT_TIMEOUT_MS) + timeout_ms = int(self.env.get("EPP_PROVIDER_TIMEOUT_MS") or DEFAULT_TIMEOUT_MS) except (TypeError, ValueError): timeout_ms = DEFAULT_TIMEOUT_MS try: @@ -113,12 +113,8 @@ def _resolve_credential(self, auth): return {"mode": "apiKey", "secret": secret, "identity": identity} def _resolve_endpoint(self, manifest): - id_upper = manifest["id"].upper() - use_eudb = (self.env.get("EUDB") or "").lower() == "true" - eudb_endpoint = self.env.get(f"{id_upper}_ENDPOINT_EUDB") - if use_eudb and eudb_endpoint: - return eudb_endpoint - return self.env.get(f"{id_upper}_ENDPOINT") + # One provider is active per deployment, so the endpoint is a single EPP_PROVIDER_ENDPOINT. + return self.env.get("EPP_PROVIDER_ENDPOINT") def _fail_body(self, provider, channel, reason, dispatch, request_id): return {"status": "failed", "outcome": "Fail", "provider": provider, "channel": channel, "reason": reason, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} diff --git a/python/src/providers/infobip.py b/python/src/providers/infobip.py index 2cc5cdd..097eb0a 100644 --- a/python/src/providers/infobip.py +++ b/python/src/providers/infobip.py @@ -18,14 +18,14 @@ class InfobipProvider: } def build_request(self, channel, endpoint, dispatch, credential, env): - sender_id = env.get("INFOBIP_SENDER_ID") or "Verify" + sender_id = env.get("EPP_PROVIDER_ACCOUNT_NAME") or "Verify" authorization = f"Bearer {credential['token']}" if credential["mode"] == "oauth2" else f"App {credential['secret']}" headers = {"Authorization": authorization, "Content-Type": "application/json", "Accept": "application/json"} message_id = dispatch.correlation_id or dispatch.message_id if channel == "voice": body = {"messages": [{ - "from": env.get("INFOBIP_VOICE_FROM") or sender_id, + "from": sender_id, "destinations": [{"to": dispatch.destination, "messageId": message_id}], "text": dispatch.message, "language": dispatch.locale or "en", diff --git a/python/src/providers/sinch.py b/python/src/providers/sinch.py index e76bed1..0638908 100644 --- a/python/src/providers/sinch.py +++ b/python/src/providers/sinch.py @@ -30,7 +30,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env): service_plan_id = env.get("SINCH_SERVICE_PLAN_ID") or "" body = { - "from": env.get("SINCH_SENDER_ID") or "Verify", + "from": env.get("EPP_PROVIDER_ACCOUNT_NAME") or "Verify", "to": [dispatch.destination], "body": dispatch.message, "client_reference": reference, diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index ea6853d..ac44b8f 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -31,12 +31,14 @@ def build_request(self, channel, endpoint, dispatch, credential, env): body = {"messageType": message_type, "destination": dispatch.destination, "clientReference": client_reference} # Sender: a provisioned source endpoint is what Soprano accepts; free-text source is a fallback. - source_id = env.get("SOPRANO_SOURCE_ID") - if source_id: + # Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A + # non-numeric account name is sent as a free-text source instead. + account = env.get("EPP_PROVIDER_ACCOUNT_NAME") + if account and str(account).isdigit(): source_type = int(env.get("SOPRANO_SOURCE_TYPE") or 1) - body["endpoints"] = [{"type": source_type, "id": int(source_id)}] - elif env.get("SOPRANO_SENDER_ID"): - body["source"] = env.get("SOPRANO_SENDER_ID") + body["endpoints"] = [{"type": source_type, "id": int(account)}] + elif account: + body["source"] = account if message_type == "voice": locale = dispatch.locale or "" diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py index 2fdb01a..b03276c 100644 --- a/python/src/providers/telesign.py +++ b/python/src/providers/telesign.py @@ -41,7 +41,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env): form = { "phone_number": dispatch.destination, "message": dispatch.message or "", - "sender_id": env.get("TELESIGN_SENDER_ID") or "", + "sender_id": env.get("EPP_PROVIDER_ACCOUNT_NAME") or "", "message_type": "OTP", "external_id": external_id, "is_primary": "true", diff --git a/python/src/registry.py b/python/src/registry.py index e83b8c1..f27263b 100644 --- a/python/src/registry.py +++ b/python/src/registry.py @@ -12,5 +12,5 @@ def get(self, provider_id): return self._by_id.get(provider_id.lower()) def resolve(self, request_provider): - provider_id = request_provider or os.environ.get("DEFAULT_PROVIDER") + provider_id = request_provider or os.environ.get("EPP_PROVIDER_NAME") return self.get(provider_id) diff --git a/python/src/security.py b/python/src/security.py index 75113de..3f1ce77 100644 --- a/python/src/security.py +++ b/python/src/security.py @@ -1,4 +1,4 @@ -"""Validates the Entra JWT when REQUIRE_AUTH=true (aud/issuer/JWKS, RS256). +"""Validates the Entra JWT when EPP_REQUIRE_AUTH=true (aud/issuer/JWKS, RS256). No-op pass-through otherwise.""" import os @@ -19,11 +19,11 @@ def _jwks_client(tenant_id): def validate_token(authorization_header): """Returns (ok, reason, caller_object_id).""" - if (os.environ.get("REQUIRE_AUTH") or "").lower() != "true": + if (os.environ.get("EPP_REQUIRE_AUTH") or "").lower() != "true": return True, None, None - audience = os.environ.get("EXPECTED_AUDIENCE") - tenant_id = os.environ.get("ISSUER_TENANT_ID") + audience = os.environ.get("EPP_EXPECTED_AUDIENCE") + tenant_id = os.environ.get("EPP_TENANT_ID") if not audience or not tenant_id: return False, "auth misconfigured", None @@ -41,8 +41,12 @@ def validate_token(authorization_header): options={"verify_iss": False}, ) allowed_issuers = ( - f"https://login.microsoftonline.com/{tenant_id}/v2.0", - f"https://sts.windows.net/{tenant_id}/", + (os.environ.get("EPP_EXPECTED_ISSUER"),) + if os.environ.get("EPP_EXPECTED_ISSUER") + else ( + f"https://login.microsoftonline.com/{tenant_id}/v2.0", + f"https://sts.windows.net/{tenant_id}/", + ) ) if claims.get("iss") not in allowed_issuers: return False, "token validation failed", None diff --git a/python/tests/test_cyot.py b/python/tests/test_cyot.py index c9eaf74..15e36d2 100644 --- a/python/tests/test_cyot.py +++ b/python/tests/test_cyot.py @@ -7,7 +7,6 @@ context_to_dispatch, decrypt_delivery_context, parse_envelope, - read_kid, ) # Throwaway RSA key: encrypt here, decrypt via the module using the private PEM. @@ -60,8 +59,10 @@ def test_valid_envelope_parses(): def test_jwe_round_trips_to_delivery_context(): compact = _encrypt(_sample_context()) - assert read_kid(compact) == "test-key" - context = decrypt_delivery_context(compact, _key_provider) + header, context = decrypt_delivery_context(compact, _key_provider) + assert header["kid"] == "test-key" + assert header["alg"] == "RSA-OAEP-256" + assert header["enc"] == "A256GCM" assert context["nonce"] == "nonce-1" assert context["phoneNumber"] == "+14255551234" assert context["message"] == "Your code is 123456" @@ -76,3 +77,19 @@ def test_context_to_dispatch_maps_fields(): assert dispatch.channel == "voice" assert dispatch.message_id == "msg-1" assert dispatch.correlation_id == "corr-1" + # Voice must read the passcode digit by digit. + assert "1 2 3 4 5 6" in dispatch.message + + +def test_sms_message_is_left_intact(): + envelope, _ = parse_envelope({"channel": 1, "mode": 1, "encryptedDeliveryContext": "x"}) + dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") + assert dispatch.message == "Your code is 123456" + + +def test_base64_wrapped_key_is_accepted(): + """The setup script stores EPP_DECRYPTION_KEY_PEM as base64 over the PEM.""" + import base64 as _b64 + wrapped = _b64.b64encode(_PRIVATE_PEM.encode("utf-8")).decode("ascii") + _header, context = decrypt_delivery_context(_encrypt(_sample_context()), lambda _kid: wrapped) + assert context["nonce"] == "nonce-1" diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index 65554c1..b37de8e 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -48,10 +48,7 @@ def _record(self, fmt, *args): "soprano-api-key": "sp", "soprano-api-id": "spid", } _DEFAULT_ENV = { - "INFOBIP_ENDPOINT": "https://api.infobip.com", - "TELESIGN_ENDPOINT": "https://rest-api.telesign.com", - "SOPRANO_ENDPOINT": "https://mems.example.com/cgpapi", - "SINCH_ENDPOINT": "https://sms.api.sinch.com", + "EPP_PROVIDER_ENDPOINT": "https://api.infobip.com", } From 4a4d7d4e85dc65c29361c9dd52fef81d311f0cf1 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 09:45:33 -0700 Subject: [PATCH 06/17] Simplify the JavaScript dispatch layer Replaces the directory-scanning provider registry with four static requires. The scan swallowed load errors into a console.warn, so a typo in a provider file surfaced as an unknown-provider 400 at request time instead of failing at startup; it also did not survive bundling. Inlines the single-line EPP_PROVIDER_ENDPOINT indirection, and logs outcome, providerStatus and providerMessageId on the background delivery so the dispatch result is no longer write-only. Corrects two comments still naming the pre-rename DEFAULT_PROVIDER setting. --- dotnet/Src/ProviderRegistry.cs | 2 +- javascript/src/functions/SendOtp.js | 4 +- javascript/src/functions/dispatch.js | 68 ++++++++-------------------- 3 files changed, 21 insertions(+), 53 deletions(-) diff --git a/dotnet/Src/ProviderRegistry.cs b/dotnet/Src/ProviderRegistry.cs index 0151043..1f98b7e 100644 --- a/dotnet/Src/ProviderRegistry.cs +++ b/dotnet/Src/ProviderRegistry.cs @@ -1,7 +1,7 @@ namespace Cyot.Otp; // Registry of provider adapters (keyed by lowercased id), and resolution of the active provider: -// the request's Provider, else the deployment's DEFAULT_PROVIDER. One provider active per deployment. +// the request's Provider, else the deployment's EPP_PROVIDER_NAME. One provider active per deployment. public sealed class ProviderRegistry { private readonly IReadOnlyDictionary _byId; diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index c363ff7..6414f45 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -56,8 +56,8 @@ function deliverInBackground(dispatch, envelope, evaluation, config, context, re context, requestId, }) - .then(({ httpStatus }) => { - context.log(`${TAG} provider result : httpStatus=${httpStatus}`); + .then(({ httpStatus, body }) => { + context.log(`${TAG} provider result : httpStatus=${httpStatus} outcome=${body.outcome || 'n/a'} providerStatus=${body.providerStatus || 'n/a'} providerMessageId=${body.providerMessageId || 'n/a'}`); }) .catch((deliveryError) => { (context.error || context.log).call(context, `${TAG} provider delivery failed: ${deliveryError.message}`); diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index c03a228..d2df7aa 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -9,8 +9,6 @@ // outcome and an HTTP status. Fail-closed: only a Continue outcome is "accepted". -const fileSystem = require('fs'); -const path = require('path'); const { ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); @@ -46,53 +44,29 @@ const DEFAULTS = Object.freeze({ CHANNELS: ['sms', 'voice'], }); -const PROVIDERS_DIRECTORY = path.join(__dirname, 'providers'); const SECRET_CACHE_TIME_TO_LIVE_MILLISECONDS = 5 * 60 * 1000; // rotated secrets picked up within this window // ─── Provider registry ─────────────────────────────────────────────────────── -// Each ./providers/.js exports { manifest, buildRequest, parseResponse }. Onboarding = drop in a file. - -let providerRegistry = null; - -// Builds and caches the registry once (keyed by lowercased id); a malformed file is skipped, not fatal. -function loadProviderRegistry() { - if (providerRegistry) { - return providerRegistry; - } - providerRegistry = new Map(); - - let providerFileNames = []; - try { - providerFileNames = fileSystem.readdirSync(PROVIDERS_DIRECTORY).filter((fileName) => fileName.endsWith('.js')); - } catch { - providerFileNames = []; - } - - for (const fileName of providerFileNames) { - try { - // eslint-disable-next-line global-require - const providerModule = require(path.join(PROVIDERS_DIRECTORY, fileName)); - const manifest = providerModule && providerModule.manifest; - if (!manifest || !manifest.id) { - continue; - } - providerRegistry.set(String(manifest.id).toLowerCase(), { manifest, adapter: providerModule }); - } catch (error) { - console.warn(`[providers] skipping '${fileName}': ${error.message}`); - } - } - - return providerRegistry; -} +// Each ./providers/.js exports { manifest, buildRequest, parseResponse }. Onboarding a provider is +// a new file plus one line here — static so a broken provider fails at load, not mid-request. + +const providerRegistry = new Map( + [ + require('./providers/infobip'), + require('./providers/sinch'), + require('./providers/soprano'), + require('./providers/telesign'), + ].map((providerModule) => [ + providerModule.manifest.id.toLowerCase(), + { manifest: providerModule.manifest, adapter: providerModule }, + ]), +); function getProvider(providerId) { - if (!providerId) { - return null; - } - return loadProviderRegistry().get(String(providerId).toLowerCase()) || null; + return providerId ? providerRegistry.get(String(providerId).toLowerCase()) || null : null; } -// The request's Provider, else the deployment's DEFAULT_PROVIDER (set by UX at provisioning). One +// The request's Provider, else the deployment's EPP_PROVIDER_NAME (set by UX at provisioning). One // provider is active per deployment — selection is config, not routing the endpoint performs. function resolveProvider(requestProvider) { const providerId = (requestProvider || process.env.EPP_PROVIDER_NAME || '').toLowerCase(); @@ -179,13 +153,7 @@ async function resolveProviderCredential(authConfiguration = {}, options = {}) { return { mode: 'apiKey', secret, identity }; } -// ─── Endpoint & outcome mapping ────────────────────────────────────────────────── - -// Base URL from app settings (UX-provisioned): one provider is active per deployment, so the endpoint -// is a single EPP_PROVIDER_ENDPOINT rather than a per-provider key. -function resolveEndpointBaseUrl(environmentVariables) { - return environmentVariables.EPP_PROVIDER_ENDPOINT; -} +// ─── Outcome mapping ───────────────────────────────────────────────────────────── // Translates the provider's parsed status into a normalized outcome. A recognized status wins; an // unknown status is fail-closed; only a status-less response trusts the HTTP result. @@ -279,7 +247,7 @@ async function sendViaProvider(providerEntry, dispatch, options) { return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; } - const endpointBaseUrl = resolveEndpointBaseUrl(process.env); + const endpointBaseUrl = process.env.EPP_PROVIDER_ENDPOINT; if (!endpointBaseUrl) { writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} endpoint not configured`); return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider endpoint not configured', dispatch, requestId) }; From 7b50896a6cb23df32cc3c3b42e8b6aa3f3ec3123 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 09:49:09 -0700 Subject: [PATCH 07/17] Remove the unimplemented EPP_PROVIDER_RETRY_INTERVAL_MS setting from the docs No implementation has read this setting since the retry path was dropped, so the samples were advertising a knob that does nothing. --- docs/CONTRACT.md | 1 - docs/local.settings.sample.json | 1 - javascript/README.md | 1 - 3 files changed, 3 deletions(-) diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 02ac19a..33f550c 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -120,7 +120,6 @@ Set by provisioning. **Identical names across all languages.** | `EPP_PROVIDER_ENDPOINT` | provider base URL (one provider is active per deployment) | | `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider | | `EPP_PROVIDER_TIMEOUT_MS` | outbound call timeout (default 1500) | -| `EPP_PROVIDER_RETRY_INTERVAL_MS` | retry interval, reported at startup | | `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 | | `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | | `EPP_REQUIRE_AUTH` | `true` → validate the Entra token in-process (Easy Auth is the primary gate) | diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index f0970a1..7367c72 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -17,7 +17,6 @@ "EPP_PROVIDER_ENDPOINT": "https://", "EPP_PROVIDER_ACCOUNT_NAME": "", "EPP_PROVIDER_TIMEOUT_MS": "1500", - "EPP_PROVIDER_RETRY_INTERVAL_MS": "0", "_comment_log_plaintext": "DIAGNOSTICS ONLY. true writes the phone number and passcode to the log. Never enable in production.", "EPP_LOG_PLAINTEXT": "false", diff --git a/javascript/README.md b/javascript/README.md index 878c6ef..1dce5e6 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -58,7 +58,6 @@ Key Vault and can be rotated there without a redeploy. | `EPP_PROVIDER_ENDPOINT` | provider base URL (one provider is active per deployment) | | `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider | | `EPP_PROVIDER_TIMEOUT_MS` | outbound provider-call timeout in ms (default `1500`) | -| `EPP_PROVIDER_RETRY_INTERVAL_MS` | retry interval in ms (reported at startup) | | `EPP_DECRYPTION_KEY_PEM` | RSA private key PEM for JWE decryption — a **Key Vault reference** in Azure | | `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | | `EPP_EXPECTED_CLIENT_ID` | caller `appid` Easy Auth should admit; a mismatch returns `403` | From ac26baf8b589ffea38e11c2dac4c24b305a00639 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 14:47:58 -0700 Subject: [PATCH 08/17] Drop the redundant passcode log line from all three samples The message logged on the preceding line already contains the passcode, so extracting it again only wrote a second copy of the secret to the log. Removes the helper from all three languages and the now-unused re import in Python. --- dotnet/Functions/SendOtp.cs | 7 +------ javascript/src/functions/SendOtp.js | 7 ------- python/function_app.py | 8 -------- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index 155cc06..cdd8106 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -56,14 +56,10 @@ public SendOtp(DispatchEngine engine, TokenValidator tokens, JweDecryptor decryp } } - // Lifts the passcode out of the rendered sentence, purely so it is easy to eyeball in the log. - private static string? ExtractPasscode(string? message) => - string.IsNullOrEmpty(message) ? null : Regex.Match(message, @"\b\d{4,8}\b") is { Success: true } m ? m.Value : null; - // Voice: left alone, a TTS engine reads 641895 as "six hundred forty-one thousand eight hundred // ninety-five", which no user can type. Spacing the digits makes it read them one at a time. private static string? SpacePasscodeForVoice(string? message) => - string.IsNullOrEmpty(message) ? message : Regex.Replace(message, @"\b\d{4,8}\b", m => string.Join(" ", m.Value.ToCharArray()), RegexOptions.None); + string.IsNullOrEmpty(message) ? message : Regex.Replace(message, @"\b\d{4,8}\b", m => string.Join(" ", m.Value.ToCharArray())); [Function("SendOtp")] public async Task Run( @@ -161,7 +157,6 @@ public async Task Run( Log("extension", context.Extension ?? "(none)"); Log("locale", context.Locale); Log("message", context.Message); - Log("passcode", ExtractPasscode(context.Message) ?? "(none found)"); Log("riskContext", context.RiskContext.HasValue ? context.RiskContext.Value.ToString() : "(none)"); } else diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index 6414f45..ca491c5 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -35,12 +35,6 @@ function readCallerAppId(request) { } } -// Lifts the passcode out of the rendered sentence, purely so it is easy to eyeball in the log. -function extractPasscode(message) { - const match = /\b\d{4,8}\b/.exec(message || ''); - return match ? match[0] : undefined; -} - const pad = (label) => label.padEnd(18, ' '); // The handler deliberately does not await the provider, so tests need a handle on the send it started. @@ -157,7 +151,6 @@ app.http('SendOtp', { log('extension', delivery.extension || '(none)'); log('locale', delivery.locale); log('message', delivery.message); - log('passcode', extractPasscode(delivery.message) || '(none found)'); log('riskContext', delivery.riskContext ? JSON.stringify(delivery.riskContext) : '(none)'); } else { context.log(`${TAG} plaintext suppressed (EPP_LOG_PLAINTEXT=false)`); diff --git a/python/function_app.py b/python/function_app.py index 1e8d930..5f7af06 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -9,7 +9,6 @@ import json import logging import os -import re import threading import time import uuid @@ -65,12 +64,6 @@ def _read_caller_app_id(req): return None -def _extract_passcode(message): - """Lifts the passcode out of the rendered sentence, purely so it is easy to eyeball in the log.""" - match = re.search(r"\b\d{4,8}\b", message or "") - return match.group(0) if match else None - - @app.route(route="SendOtp", methods=["POST"], auth_level=func.AuthLevel.ANONYMOUS) def send_otp(req: func.HttpRequest) -> func.HttpResponse: started = time.time() @@ -144,7 +137,6 @@ def send_otp(req: func.HttpRequest) -> func.HttpResponse: _log("extension", delivery.get("extension") or "(none)") _log("locale", delivery.get("locale")) _log("message", delivery.get("message")) - _log("passcode", _extract_passcode(delivery.get("message")) or "(none found)") _log("riskContext", json.dumps(delivery["riskContext"]) if delivery.get("riskContext") else "(none)") else: logging.info("%s plaintext suppressed (EPP_LOG_PLAINTEXT=false)", TAG) From 4cb44a4153475f6bce01d68e6a26685278adfddc Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 14:54:31 -0700 Subject: [PATCH 09/17] Enforce the expected caller inside token validation, not only via Easy Auth EPP_EXPECTED_CLIENT_ID was only ever compared against the Easy Auth x-ms-client-principal header, and the comparison was skipped when that header was absent. None of the three token validators inspected azp or appid, so with Easy Auth disabled and EPP_REQUIRE_AUTH=true any application in the tenant holding a token for the audience was accepted. All three validators now reject a token whose azp/appid does not match EPP_EXPECTED_CLIENT_ID. Adds five tests over the extracted isExpectedCaller decision. --- docs/CONTRACT.md | 2 +- dotnet/Src/TokenValidator.cs | 11 +++++++++++ javascript/README.md | 2 +- javascript/src/functions/security.js | 17 +++++++++++++++-- javascript/test/auth.test.js | 24 +++++++++++++++++++++++- python/src/security.py | 7 +++++++ 6 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 33f550c..f7f40f8 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -126,7 +126,7 @@ Set by provisioning. **Identical names across all languages.** | `EPP_EXPECTED_AUDIENCE` | v1 token `aud` — the identifier URI `api://{host}/{appId}` | | `EPP_EXPECTED_ISSUER` | v1 issuer `https://sts.windows.net/{tenantId}/` | | `EPP_TENANT_ID` | your Entra tenant id | -| `EPP_EXPECTED_CLIENT_ID` | caller `appid` Easy Auth should admit — Microsoft's app `25ec60fa-f18d-41a4-b398-50044c90ce13`; a mismatch returns `403` | +| `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`) | | `EPP_LOG_PLAINTEXT` | **diagnostics only** — `true` writes the phone number and passcode to the log. Never enable in production | | `KEY_VAULT_URL` | Key Vault URI (provider API keys) | | `AZURE_CLIENT_ID` | set for a user-assigned managed identity | diff --git a/dotnet/Src/TokenValidator.cs b/dotnet/Src/TokenValidator.cs index a780649..0af679c 100644 --- a/dotnet/Src/TokenValidator.cs +++ b/dotnet/Src/TokenValidator.cs @@ -51,6 +51,17 @@ public async Task ValidateAsync(string? authorizationHeader) ValidateIssuerSigningKey = true, }; var principal = _handler.ValidateToken(token, parameters, out _); + + // Easy Auth's allowedApplications does this at the platform, but it is off in the standalone + // configuration, so without this any app in the tenant could reach the endpoint. + var expectedClientId = Environment.GetEnvironmentVariable("EPP_EXPECTED_CLIENT_ID"); + if (!string.IsNullOrEmpty(expectedClientId)) + { + var callerAppId = principal.FindFirst("azp")?.Value ?? principal.FindFirst("appid")?.Value; + if (!string.Equals(callerAppId, expectedClientId, StringComparison.OrdinalIgnoreCase)) + return new Result(false, "unexpected caller"); + } + var oid = principal.FindFirst("oid")?.Value ?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value; return new Result(true, CallerObjectId: oid); } diff --git a/javascript/README.md b/javascript/README.md index 1dce5e6..ceb912e 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -60,7 +60,7 @@ Key Vault and can be rotated there without a redeploy. | `EPP_PROVIDER_TIMEOUT_MS` | outbound provider-call timeout in ms (default `1500`) | | `EPP_DECRYPTION_KEY_PEM` | RSA private key PEM for JWE decryption — a **Key Vault reference** in Azure | | `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | -| `EPP_EXPECTED_CLIENT_ID` | caller `appid` Easy Auth should admit; a mismatch returns `403` | +| `EPP_EXPECTED_CLIENT_ID` | caller `appid`/`azp` to admit; Easy Auth returns `403`, in-process validation returns `401` | | `EPP_REQUIRE_AUTH` | `true` to also validate the token in-process — enable in any real deployment | | `EPP_EXPECTED_AUDIENCE` | token `aud` (this endpoint's app registration appId) — required when `EPP_REQUIRE_AUTH=true` | | `EPP_TENANT_ID` | customer tenant id for issuer/JWKS — required when `EPP_REQUIRE_AUTH=true` | diff --git a/javascript/src/functions/security.js b/javascript/src/functions/security.js index 807a35e..4db7247 100644 --- a/javascript/src/functions/security.js +++ b/javascript/src/functions/security.js @@ -21,6 +21,14 @@ function getJwks(issuerTenantId) { return jwksByTenant.get(issuerTenantId); } +// Easy Auth's allowedApplications does this at the platform, but it is off in the standalone +// configuration, so without this any app in the tenant could reach the endpoint. +// azp is the v2 claim, appid the v1 one. +function isExpectedCaller(payload, expectedClientId) { + if (!expectedClientId) return true; + return (payload.azp || payload.appid) === expectedClientId; +} + // Validates the inbound bearer token when REQUIRE_AUTH is enabled (issuer, audience, expiry, RS256). async function validateToken(request, context, requestId) { if (String(process.env.EPP_REQUIRE_AUTH || 'false').toLowerCase() !== 'true') { @@ -49,11 +57,16 @@ async function validateToken(request, context, requestId) { `https://login.microsoftonline.com/${tenantId}/v2.0`, `https://sts.windows.net/${tenantId}/`, ]; - await jwtVerify(bearerToken, getJwks(tenantId), { + const { payload } = await jwtVerify(bearerToken, getJwks(tenantId), { audience, issuer: issuers, algorithms: ['RS256'], }); + + if (!isExpectedCaller(payload, process.env.EPP_EXPECTED_CLIENT_ID)) { + context.log(`[AUTH_FAIL] requestId=${requestId} reason=unexpected caller appid=${payload.azp || payload.appid || 'none'}`); + return { ok: false, reason: 'unexpected caller' }; + } return { ok: true }; } catch (error) { context.log(`[AUTH_FAIL] requestId=${requestId} reason=${error.message}`); @@ -61,4 +74,4 @@ async function validateToken(request, context, requestId) { } } -module.exports = { validateToken }; +module.exports = { validateToken, isExpectedCaller }; diff --git a/javascript/test/auth.test.js b/javascript/test/auth.test.js index f885648..6116541 100644 --- a/javascript/test/auth.test.js +++ b/javascript/test/auth.test.js @@ -2,7 +2,7 @@ const { test, afterEach } = require('node:test'); const assert = require('node:assert'); -const { validateToken } = require('../src/functions/security'); +const { validateToken, isExpectedCaller } = require('../src/functions/security'); const ctx = { log() {} }; const reqWith = (headers = {}) => ({ headers: { get: (k) => headers[k.toLowerCase()] || null } }); @@ -11,6 +11,7 @@ afterEach(() => { delete process.env.EPP_REQUIRE_AUTH; delete process.env.EPP_EXPECTED_AUDIENCE; delete process.env.EPP_TENANT_ID; + delete process.env.EPP_EXPECTED_CLIENT_ID; }); test('skips validation when REQUIRE_AUTH is not true', async () => { @@ -43,3 +44,24 @@ test('fails (generic reason) on an invalid token', async () => { assert.equal(r.ok, false); assert.equal(r.reason, 'token validation failed'); }); + +// Easy Auth normally rejects the wrong caller at the platform; these cover the standalone path. +test('caller check: unpinned client id accepts any caller', () => { + assert.equal(isExpectedCaller({ azp: 'anything' }, ''), true); +}); + +test('caller check: rejects an unexpected caller', () => { + assert.equal(isExpectedCaller({ azp: 'some-other-app' }, 'expected-app'), false); +}); + +test('caller check: rejects a token carrying no caller claim', () => { + assert.equal(isExpectedCaller({}, 'expected-app'), false); +}); + +test('caller check: accepts the v2 azp claim', () => { + assert.equal(isExpectedCaller({ azp: 'expected-app' }, 'expected-app'), true); +}); + +test('caller check: accepts the v1 appid claim', () => { + assert.equal(isExpectedCaller({ appid: 'expected-app' }, 'expected-app'), true); +}); diff --git a/python/src/security.py b/python/src/security.py index 3f1ce77..bcc6d16 100644 --- a/python/src/security.py +++ b/python/src/security.py @@ -50,6 +50,13 @@ def validate_token(authorization_header): ) if claims.get("iss") not in allowed_issuers: return False, "token validation failed", None + + # Easy Auth's allowedApplications does this at the platform, but it is off in the standalone + # configuration, so without this any app in the tenant could reach the endpoint. + expected_client_id = os.environ.get("EPP_EXPECTED_CLIENT_ID") + if expected_client_id and (claims.get("azp") or claims.get("appid")) != expected_client_id: + return False, "unexpected caller", None + return True, None, claims.get("oid") except Exception: return False, "token validation failed", None From 4398c5b6f1b7b89bcf26b0233e870185fba63281 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 14:57:43 -0700 Subject: [PATCH 10/17] Drop config fields nothing reads, and log the delivery outcome in Python config.js claimed to hold every setting the endpoint reads, but dispatch.js and security.js read their own directly. Its provider.accountName and provider.timeoutMs were read nowhere, and its DEFAULT_TIMEOUT_MS was a second competing definition of the 1500 ms default that dispatch.js already owns. Comment now describes what the module actually is. Python discarded the dispatch body; it now logs outcome, providerStatus and providerMessageId on the [EPP] line as JavaScript does. --- javascript/src/functions/config.js | 10 +++------- python/function_app.py | 7 +++++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js index 50edd35..a0c998b 100644 --- a/javascript/src/functions/config.js +++ b/javascript/src/functions/config.js @@ -4,11 +4,9 @@ 'use strict'; -// Every setting the endpoint reads, in one place. Names are the EPP_* app settings provisioned on the -// Function App; EPP_DECRYPTION_KEY_PEM is a Key Vault reference, so the runtime only ever sees the -// resolved PEM and never calls Key Vault itself. - -const DEFAULT_TIMEOUT_MS = 1500; +// The settings the request handler itself needs. dispatch.js and security.js read their own settings +// directly, so each module stays readable on its own; EPP_DECRYPTION_KEY_PEM is a Key Vault reference, +// so the runtime only ever sees the resolved PEM and never calls Key Vault itself. // Read per call so a settings change (or a test) is picked up without a cold start. function readConfig(env = process.env) { @@ -24,8 +22,6 @@ function readConfig(env = process.env) { provider: { name: env.EPP_PROVIDER_NAME || '', endpoint: env.EPP_PROVIDER_ENDPOINT || '', - accountName: env.EPP_PROVIDER_ACCOUNT_NAME || '', - timeoutMs: Number(env.EPP_PROVIDER_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS, }, }; } diff --git a/python/function_app.py b/python/function_app.py index 5f7af06..19593cc 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -152,8 +152,11 @@ def send_otp(req: func.HttpRequest) -> func.HttpResponse: # Microsoft allows 3.2 s for the whole call, so the provider is called after the response. def _deliver(): try: - status, _body = _engine.dispatch(dispatch, None, evaluation, request_id, logging) - logging.info("%s provider result : httpStatus=%s correlationId=%s", TAG, status, correlation_id) + status, body = _engine.dispatch(dispatch, None, evaluation, request_id, logging) + logging.info( + "%s provider result : httpStatus=%s outcome=%s providerStatus=%s providerMessageId=%s correlationId=%s", + TAG, status, body.get("outcome") or "n/a", body.get("providerStatus") or "n/a", + body.get("providerMessageId") or "n/a", correlation_id) except Exception as delivery_error: logging.error("%s provider delivery failed: %s", TAG, delivery_error) From 4def2924f9ce251bff2012334ea126a087bb06b3 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 15:03:33 -0700 Subject: [PATCH 11/17] Fold cyot.js into dispatch.js The envelope parsing and JWE decryption now sit at the top of dispatch.js in request order, ahead of the provider dispatch code. No test imported cyot.js directly, so the fold is confined to the module boundary. --- javascript/src/functions/SendOtp.js | 9 +- javascript/src/functions/cyot.js | 145 -------------------------- javascript/src/functions/dispatch.js | 148 ++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 152 deletions(-) delete mode 100644 javascript/src/functions/cyot.js diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index ca491c5..392fbe6 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -15,8 +15,13 @@ const { app } = require('@azure/functions'); const crypto = require('crypto'); const { validateToken } = require('./security'); -const { dispatchOtp } = require('./dispatch'); -const { parseEnvelope, decryptDeliveryContext, contextToDispatch, MODE } = require('./cyot'); +const { + dispatchOtp, + parseEnvelope, + decryptDeliveryContext, + contextToDispatch, + MODE, +} = require('./dispatch'); const { readConfig, missingSettings } = require('./config'); const TAG = '[EPP]'; diff --git a/javascript/src/functions/cyot.js b/javascript/src/functions/cyot.js deleted file mode 100644 index fbe260e..0000000 --- a/javascript/src/functions/cyot.js +++ /dev/null @@ -1,145 +0,0 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// - -'use strict'; - -// SAS → CYOT envelope handling. SAS sends a cleartext routing envelope (SendCyotOtpRequest) whose PII -// (phone + rendered message, which contains the passcode) is encrypted in a JWE. This module parses the -// envelope, decrypts the JWE delivery context (RSA-OAEP-256 + A256GCM, key selected by the JOSE `kid`), -// and maps the CyotDeliveryContext onto the dispatch engine's request shape. - -const crypto = require('crypto'); -const { compactDecrypt } = require('jose'); -const { readConfig } = require('./config'); - -// CyotChannel: 1=Sms, 2=Voice (0=Undefined). CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). -const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); -const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); -const MODE = Object.freeze({ LIVE: 1, EVALUATION: 2 }); -const MODE_BY_NAME = Object.freeze({ live: 1, evaluation: 2 }); - -// channel/mode accept the int enum (1/2) or the string form ("sms"/"voice", "live"/"evaluation"). -function normalizeChannel(channel) { - if (CHANNEL_BY_CODE[channel]) return Number(channel); - if (typeof channel === 'string' && CHANNEL_BY_NAME[channel.toLowerCase()]) return CHANNEL_BY_NAME[channel.toLowerCase()]; - return null; -} -function normalizeMode(mode) { - if (mode === MODE.LIVE || mode === MODE.EVALUATION) return mode; - if (typeof mode === 'string' && MODE_BY_NAME[mode.toLowerCase()]) return MODE_BY_NAME[mode.toLowerCase()]; - return null; -} - -// Validates + normalizes the cleartext envelope. Returns { envelope } or { error }. -function parseEnvelope(payload) { - if (!payload || typeof payload !== 'object') { - return { error: 'invalid envelope' }; - } - const { type, tenantId, correlationId, channel, mode, ttlSeconds, encryptedDeliveryContext } = payload; - if (typeof encryptedDeliveryContext !== 'string' || !encryptedDeliveryContext) { - return { error: 'encryptedDeliveryContext is required' }; - } - const channelCode = normalizeChannel(channel); - if (!channelCode) { - return { error: `unsupported channel '${channel}'` }; - } - const modeCode = normalizeMode(mode); - if (!modeCode) { - return { error: `unsupported mode '${mode}'` }; - } - return { envelope: { type, tenantId, correlationId, channel: channelCode, mode: modeCode, ttlSeconds, encryptedDeliveryContext } }; -} - -// Reject oversized or structurally invalid JWEs before base64-decoding or allocating buffers. -const MAX_JWE_LENGTH = 16384; - -function assertWellFormedJwe(compactJwe) { - if (typeof compactJwe !== 'string' || compactJwe.length === 0) { - throw new Error('malformed JWE'); - } - if (compactJwe.length > MAX_JWE_LENGTH) { - throw new Error('delivery context exceeds size limit'); - } - const segments = compactJwe.split('.'); - if (segments.length !== 5 || segments.some((segment) => segment.length === 0)) { - throw new Error('malformed JWE: expected five non-empty segments'); - } -} - -// Reads the JWE protected (first) segment without decrypting, so kid/alg/enc can be logged. -function readProtectedHeader(compactJwe) { - const protectedSegment = String(compactJwe).split('.')[0] || ''; - return JSON.parse(Buffer.from(protectedSegment, 'base64url').toString('utf8')); -} - -// The key is imported once and reused: doing it per delivery would add an RSA import inside the -// response budget and turn a bad key into a failure on every call instead of one obvious first failure. -let cachedKey; -let cachedKeyPem; - -// The setup script stores the key as base64 over the PEM so its newlines survive being carried as a -// secret and then as an app setting, so accept either form. -function normalizePem(value) { - const text = String(value || ''); - if (text.includes('-----BEGIN')) return text; - return Buffer.from(text, 'base64').toString('utf8'); -} - -function loadPrivateKey(pem) { - if (!pem) { - throw new Error('private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)'); - } - if (cachedKey && cachedKeyPem === pem) { - return cachedKey; - } - cachedKey = crypto.createPrivateKey(normalizePem(pem)); - cachedKeyPem = pem; - return cachedKey; -} - -// Decrypts the JWE compact serialization. Returns the protected header (for kid/alg logging) alongside -// the CyotDeliveryContext. `keyProvider(kid)` is injectable so tests can supply a local key. -async function decryptDeliveryContext(compactJwe, options = {}) { - const config = options.config || readConfig(options.env || process.env); - assertWellFormedJwe(compactJwe); - const header = readProtectedHeader(compactJwe); - const pem = options.keyProvider ? await options.keyProvider(header.kid) : config.decryptionKeyPem; - const privateKey = loadPrivateKey(pem); - // Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). - const { plaintext } = await compactDecrypt(compactJwe, privateKey, { - keyManagementAlgorithms: ['RSA-OAEP-256'], - contentEncryptionAlgorithms: ['A256GCM'], - }); - return { header, context: JSON.parse(Buffer.from(plaintext).toString('utf8')) }; -} - -// Maps the decrypted context + envelope onto the engine's dispatch shape. The message is pre-rendered -// (already contains the passcode), so there is no separate code — the fields mirror CyotDeliveryContext. -// Voice: left alone, a TTS engine reads 641895 as "six hundred forty-one thousand eight hundred -// ninety-five", which no user can type. Spacing the digits makes it read them one at a time. -function spacePasscodeForVoice(message) { - return String(message || '').replace(/\b\d{4,8}\b/, (digits) => digits.split('').join(' ')); -} - -function contextToDispatch(context, envelope, messageId) { - const channel = CHANNEL_BY_CODE[envelope.channel]; - return { - destination: context.phoneNumber, - message: channel === 'voice' ? spacePasscodeForVoice(context.message) : context.message, - channel, - messageId, - correlationId: envelope.correlationId, - locale: context.locale || undefined, - }; -} - -module.exports = { - parseEnvelope, - readProtectedHeader, - loadPrivateKey, - decryptDeliveryContext, - contextToDispatch, - CHANNEL_BY_CODE, - MODE, -}; diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index d2df7aa..64e91c8 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -4,13 +4,144 @@ 'use strict'; -// OTP delivery engine. Each provider is a manifest + adapter under ./providers/.js. dispatchOtp -// resolves the provider, sends via its adapter with a timeout, then maps the provider status to an -// outcome and an HTTP status. Fail-closed: only a Continue outcome is "accepted". - - +// The delivery pipeline, in request order: parse the cleartext SAS envelope, decrypt the JWE delivery +// context that carries the PII, then dispatch to the bound provider. Each provider is a manifest + +// adapter under ./providers/.js; dispatchOtp resolves the provider, sends via its adapter with a +// timeout, then maps the provider status to an outcome and an HTTP status. Fail-closed: only a +// Continue outcome is "accepted". + +const crypto = require('crypto'); +const { compactDecrypt } = require('jose'); const { ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); +const { readConfig } = require('./config'); + +// ─── SAS envelope ──────────────────────────────────────────────────────────── +// SAS sends a cleartext routing envelope (SendCyotOtpRequest) whose PII (phone + rendered message, +// which contains the passcode) is encrypted in a JWE. + +// CyotChannel: 1=Sms, 2=Voice (0=Undefined). CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). +const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); +const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); +const MODE = Object.freeze({ LIVE: 1, EVALUATION: 2 }); +const MODE_BY_NAME = Object.freeze({ live: 1, evaluation: 2 }); + +// channel/mode accept the int enum (1/2) or the string form ("sms"/"voice", "live"/"evaluation"). +function normalizeChannel(channel) { + if (CHANNEL_BY_CODE[channel]) return Number(channel); + if (typeof channel === 'string' && CHANNEL_BY_NAME[channel.toLowerCase()]) return CHANNEL_BY_NAME[channel.toLowerCase()]; + return null; +} +function normalizeMode(mode) { + if (mode === MODE.LIVE || mode === MODE.EVALUATION) return mode; + if (typeof mode === 'string' && MODE_BY_NAME[mode.toLowerCase()]) return MODE_BY_NAME[mode.toLowerCase()]; + return null; +} + +// Validates + normalizes the cleartext envelope. Returns { envelope } or { error }. +function parseEnvelope(payload) { + if (!payload || typeof payload !== 'object') { + return { error: 'invalid envelope' }; + } + const { type, tenantId, correlationId, channel, mode, ttlSeconds, encryptedDeliveryContext } = payload; + if (typeof encryptedDeliveryContext !== 'string' || !encryptedDeliveryContext) { + return { error: 'encryptedDeliveryContext is required' }; + } + const channelCode = normalizeChannel(channel); + if (!channelCode) { + return { error: `unsupported channel '${channel}'` }; + } + const modeCode = normalizeMode(mode); + if (!modeCode) { + return { error: `unsupported mode '${mode}'` }; + } + return { envelope: { type, tenantId, correlationId, channel: channelCode, mode: modeCode, ttlSeconds, encryptedDeliveryContext } }; +} + +// ─── JWE delivery context ──────────────────────────────────────────────────── + +// Reject oversized or structurally invalid JWEs before base64-decoding or allocating buffers. +const MAX_JWE_LENGTH = 16384; + +function assertWellFormedJwe(compactJwe) { + if (typeof compactJwe !== 'string' || compactJwe.length === 0) { + throw new Error('malformed JWE'); + } + if (compactJwe.length > MAX_JWE_LENGTH) { + throw new Error('delivery context exceeds size limit'); + } + const segments = compactJwe.split('.'); + if (segments.length !== 5 || segments.some((segment) => segment.length === 0)) { + throw new Error('malformed JWE: expected five non-empty segments'); + } +} + +// Reads the JWE protected (first) segment without decrypting, so kid/alg/enc can be logged. +function readProtectedHeader(compactJwe) { + const protectedSegment = String(compactJwe).split('.')[0] || ''; + return JSON.parse(Buffer.from(protectedSegment, 'base64url').toString('utf8')); +} + +// The key is imported once and reused: doing it per delivery would add an RSA import inside the +// response budget and turn a bad key into a failure on every call instead of one obvious first failure. +let cachedKey; +let cachedKeyPem; + +// The setup script stores the key as base64 over the PEM so its newlines survive being carried as a +// secret and then as an app setting, so accept either form. +function normalizePem(value) { + const text = String(value || ''); + if (text.includes('-----BEGIN')) return text; + return Buffer.from(text, 'base64').toString('utf8'); +} + +function loadPrivateKey(pem) { + if (!pem) { + throw new Error('private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)'); + } + if (cachedKey && cachedKeyPem === pem) { + return cachedKey; + } + cachedKey = crypto.createPrivateKey(normalizePem(pem)); + cachedKeyPem = pem; + return cachedKey; +} + +// Decrypts the JWE compact serialization. Returns the protected header (for kid/alg logging) alongside +// the CyotDeliveryContext. `keyProvider(kid)` is injectable so tests can supply a local key. +async function decryptDeliveryContext(compactJwe, options = {}) { + const config = options.config || readConfig(options.env || process.env); + assertWellFormedJwe(compactJwe); + const header = readProtectedHeader(compactJwe); + const pem = options.keyProvider ? await options.keyProvider(header.kid) : config.decryptionKeyPem; + const privateKey = loadPrivateKey(pem); + // Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). + const { plaintext } = await compactDecrypt(compactJwe, privateKey, { + keyManagementAlgorithms: ['RSA-OAEP-256'], + contentEncryptionAlgorithms: ['A256GCM'], + }); + return { header, context: JSON.parse(Buffer.from(plaintext).toString('utf8')) }; +} + +// Voice: left alone, a TTS engine reads 641895 as "six hundred forty-one thousand eight hundred +// ninety-five", which no user can type. Spacing the digits makes it read them one at a time. +function spacePasscodeForVoice(message) { + return String(message || '').replace(/\b\d{4,8}\b/, (digits) => digits.split('').join(' ')); +} + +// Maps the decrypted context + envelope onto the engine's dispatch shape. The message is pre-rendered +// (already contains the passcode), so there is no separate code — the fields mirror CyotDeliveryContext. +function contextToDispatch(context, envelope, messageId) { + const channel = CHANNEL_BY_CODE[envelope.channel]; + return { + destination: context.phoneNumber, + message: channel === 'voice' ? spacePasscodeForVoice(context.message) : context.message, + channel, + messageId, + correlationId: envelope.correlationId, + locale: context.locale || undefined, + }; +} // ─── Shared constants ──────────────────────────────────────────────────────── @@ -342,6 +473,13 @@ async function dispatchOtp(dispatch, options) { warmUpSecretCache(); module.exports = { + parseEnvelope, + readProtectedHeader, + loadPrivateKey, + decryptDeliveryContext, + contextToDispatch, + CHANNEL_BY_CODE, + MODE, dispatchOtp, getProvider, resolveSecretValue, From a3fc399fa4d8e53e3d7c1777d92bdf96276d8966 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 15:08:14 -0700 Subject: [PATCH 12/17] Route the last three .NET classes through IEnv TokenValidator, ProviderRegistry and SecretResolver called Environment.GetEnvironmentVariable directly while every other class took the injected IEnv, so they could not be exercised with a fake environment. That is why the caller check added earlier had no .NET coverage. Extracts TokenValidator.IsExpectedCaller as a pure method, mirroring the JavaScript isExpectedCaller, and adds six tests over it and the require-auth short circuit. --- dotnet/Src/ProviderRegistry.cs | 6 ++++-- dotnet/Src/SecretResolver.cs | 7 ++++--- dotnet/Src/TokenValidator.cs | 29 ++++++++++++++++------------- dotnet/tests/ContractTests.cs | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/dotnet/Src/ProviderRegistry.cs b/dotnet/Src/ProviderRegistry.cs index 1f98b7e..f5bb9a5 100644 --- a/dotnet/Src/ProviderRegistry.cs +++ b/dotnet/Src/ProviderRegistry.cs @@ -5,10 +5,12 @@ namespace Cyot.Otp; public sealed class ProviderRegistry { private readonly IReadOnlyDictionary _byId; + private readonly IEnv _env; - public ProviderRegistry(IEnumerable adapters) + public ProviderRegistry(IEnumerable adapters, IEnv? env = null) { _byId = adapters.ToDictionary(a => a.Manifest.Id.ToLowerInvariant(), a => a); + _env = env ?? new ProcessEnv(); } public IProviderAdapter? Get(string? id) @@ -21,7 +23,7 @@ public ProviderRegistry(IEnumerable adapters) { var id = !string.IsNullOrWhiteSpace(requestProvider) ? requestProvider - : Environment.GetEnvironmentVariable("EPP_PROVIDER_NAME"); + : _env.Get("EPP_PROVIDER_NAME"); return Get(id); } } diff --git a/dotnet/Src/SecretResolver.cs b/dotnet/Src/SecretResolver.cs index b1824e3..3baea42 100644 --- a/dotnet/Src/SecretResolver.cs +++ b/dotnet/Src/SecretResolver.cs @@ -12,13 +12,14 @@ public sealed class SecretResolver : ISecretResolver private readonly ConcurrentDictionary _cache = new(); private readonly Lazy _client; - public SecretResolver() + public SecretResolver(IEnv? env = null) { + var environment = env ?? new ProcessEnv(); _client = new Lazy(() => { - var url = Environment.GetEnvironmentVariable("KEY_VAULT_URL"); + var url = environment.Get("KEY_VAULT_URL"); if (string.IsNullOrWhiteSpace(url)) return null; - var clientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"); + var clientId = environment.Get("AZURE_CLIENT_ID"); var credential = string.IsNullOrEmpty(clientId) ? new ManagedIdentityCredential() : new ManagedIdentityCredential(clientId); diff --git a/dotnet/Src/TokenValidator.cs b/dotnet/Src/TokenValidator.cs index 0af679c..e64f9d8 100644 --- a/dotnet/Src/TokenValidator.cs +++ b/dotnet/Src/TokenValidator.cs @@ -10,17 +10,26 @@ namespace Cyot.Otp; public sealed class TokenValidator { private readonly JwtSecurityTokenHandler _handler = new(); + private readonly IEnv _env; private ConfigurationManager? _configManager; + public TokenValidator(IEnv? env = null) => _env = env ?? new ProcessEnv(); + + // Easy Auth's allowedApplications does this at the platform, but it is off in the standalone + // configuration, so without this any app in the tenant could reach the endpoint. + public static bool IsExpectedCaller(string? callerAppId, string? expectedClientId) => + string.IsNullOrEmpty(expectedClientId) + || string.Equals(callerAppId, expectedClientId, StringComparison.OrdinalIgnoreCase); + public sealed record Result(bool Ok, string? Reason = null, string? CallerObjectId = null); public async Task ValidateAsync(string? authorizationHeader) { - if (!string.Equals(Environment.GetEnvironmentVariable("EPP_REQUIRE_AUTH"), "true", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(_env.Get("EPP_REQUIRE_AUTH"), "true", StringComparison.OrdinalIgnoreCase)) return new Result(true); - var audience = Environment.GetEnvironmentVariable("EPP_EXPECTED_AUDIENCE"); - var tenantId = Environment.GetEnvironmentVariable("EPP_TENANT_ID"); + var audience = _env.Get("EPP_EXPECTED_AUDIENCE"); + var tenantId = _env.Get("EPP_TENANT_ID"); if (string.IsNullOrEmpty(audience) || string.IsNullOrEmpty(tenantId)) return new Result(false, "auth misconfigured"); @@ -36,7 +45,7 @@ public async Task ValidateAsync(string? authorizationHeader) { var config = await _configManager.GetConfigurationAsync(); // EPP_EXPECTED_ISSUER pins one issuer; otherwise accept both the v2 and v1 forms. - var pinnedIssuer = Environment.GetEnvironmentVariable("EPP_EXPECTED_ISSUER"); + var pinnedIssuer = _env.Get("EPP_EXPECTED_ISSUER"); var validIssuers = string.IsNullOrEmpty(pinnedIssuer) ? new[] { $"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/" } : new[] { pinnedIssuer }; @@ -52,15 +61,9 @@ public async Task ValidateAsync(string? authorizationHeader) }; var principal = _handler.ValidateToken(token, parameters, out _); - // Easy Auth's allowedApplications does this at the platform, but it is off in the standalone - // configuration, so without this any app in the tenant could reach the endpoint. - var expectedClientId = Environment.GetEnvironmentVariable("EPP_EXPECTED_CLIENT_ID"); - if (!string.IsNullOrEmpty(expectedClientId)) - { - var callerAppId = principal.FindFirst("azp")?.Value ?? principal.FindFirst("appid")?.Value; - if (!string.Equals(callerAppId, expectedClientId, StringComparison.OrdinalIgnoreCase)) - return new Result(false, "unexpected caller"); - } + var callerAppId = principal.FindFirst("azp")?.Value ?? principal.FindFirst("appid")?.Value; + if (!IsExpectedCaller(callerAppId, _env.Get("EPP_EXPECTED_CLIENT_ID"))) + return new Result(false, "unexpected caller"); var oid = principal.FindFirst("oid")?.Value ?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value; return new Result(true, CallerObjectId: oid); diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 3f94c91..843bf30 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -16,6 +16,23 @@ private sealed class FakeEnv : Dictionary, IEnv private static DispatchRequest Disp(string channel = "sms", string? message = null) => new("+15551234567", message, channel, "m", "c", null); + // Easy Auth normally rejects the wrong caller at the platform; these cover the standalone path. + [Theory] + [InlineData("anything", "", true)] // unpinned client id accepts any caller + [InlineData("expected-app", "expected-app", true)] + [InlineData("EXPECTED-APP", "expected-app", true)] // Entra ids are case-insensitive + [InlineData("some-other-app", "expected-app", false)] + [InlineData(null, "expected-app", false)] // token carrying no caller claim + public void CallerIsCheckedAgainstExpectedClientId(string? callerAppId, string expected, bool allowed) => + Assert.Equal(allowed, TokenValidator.IsExpectedCaller(callerAppId, expected)); + + [Fact] + public void TokenValidationIsSkippedUnlessRequireAuthIsTrue() + { + var env = new FakeEnv { ["EPP_REQUIRE_AUTH"] = "false" }; + Assert.True(new TokenValidator(env).ValidateAsync("Bearer whatever").Result.Ok); + } + [Fact] public void OutcomeMappingAndHttpStatus() { From 004a9f04b771fafdd8bd5eb95b861334b53f9bb5 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 15:14:36 -0700 Subject: [PATCH 13/17] Fold envelope and JWE handling into the dispatch module in Python and .NET, and rename Cyot to Epp Matches the JavaScript layout: one delivery module per sample holding envelope parsing, JWE decryption and provider dispatch in request order. Removes python/src/cyot.py, dotnet/Src/CyotEnvelope.cs and dotnet/Src/DeliveryContext.cs. Namespace Cyot.Otp becomes Epp.Otp, CyotEnvelope becomes Envelope, CyotDeliveryContext becomes DeliveryContext, and the test project is renamed. Wire-level identifiers SendCyotOtpRequest, CyotChannel, CyotDeliveryMode and CyotEndpointResponse are left alone, since those are Microsoft's and appear on the wire. --- dotnet/Functions/SendOtp.cs | 2 +- dotnet/Program.cs | 4 +- dotnet/README.md | 4 +- dotnet/Src/CyotEnvelope.cs | 68 -------- dotnet/Src/DeliveryContext.cs | 97 ----------- dotnet/Src/DispatchEngine.cs | 160 +++++++++++++++++- dotnet/Src/IProviderAdapter.cs | 2 +- dotnet/Src/ISecretResolver.cs | 2 +- dotnet/Src/Models.cs | 2 +- dotnet/Src/OutcomeMapper.cs | 2 +- dotnet/Src/ProviderRegistry.cs | 2 +- dotnet/Src/Providers/InfobipProvider.cs | 2 +- dotnet/Src/Providers/SinchProvider.cs | 2 +- dotnet/Src/Providers/SopranoProvider.cs | 2 +- dotnet/Src/Providers/TelesignProvider.cs | 2 +- dotnet/Src/SecretResolver.cs | 2 +- dotnet/Src/TokenValidator.cs | 2 +- dotnet/tests/ContractTests.cs | 8 +- dotnet/tests/EngineTests.cs | 6 +- dotnet/tests/EnvelopeTests.cs | 4 +- ....Otp.Tests.csproj => Epp.Otp.Tests.csproj} | 0 javascript/package.json | 4 +- javascript/src/functions/dispatch.js | 4 +- javascript/test/sendotp.test.js | 2 +- python/README.md | 3 +- python/function_app.py | 4 +- python/src/cyot.py | 152 ----------------- python/src/dispatch.py | 146 +++++++++++++++- python/src/models.py | 2 +- python/tests/test_contract.py | 2 +- .../tests/{test_cyot.py => test_envelope.py} | 2 +- 31 files changed, 335 insertions(+), 361 deletions(-) delete mode 100644 dotnet/Src/CyotEnvelope.cs delete mode 100644 dotnet/Src/DeliveryContext.cs rename dotnet/tests/{Cyot.Otp.Tests.csproj => Epp.Otp.Tests.csproj} (100%) delete mode 100644 python/src/cyot.py rename python/tests/{test_cyot.py => test_envelope.py} (99%) diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index cdd8106..d7ab7ae 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; -namespace Cyot.Otp; +namespace Epp.Otp; // HTTP trigger: POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the // caller, parses the cleartext routing envelope, decrypts the JWE delivery context (PII lives there), diff --git a/dotnet/Program.cs b/dotnet/Program.cs index dc64713..f9ae1d2 100644 --- a/dotnet/Program.cs +++ b/dotnet/Program.cs @@ -1,5 +1,5 @@ -using Cyot.Otp; -using Cyot.Otp.Providers; +using Epp.Otp; +using Epp.Otp.Providers; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.DependencyInjection; diff --git a/dotnet/README.md b/dotnet/README.md index a5ac220..9c4c6d2 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -11,9 +11,7 @@ dotnet/ ├─ Program.cs # host + DI registration (add one line to onboard a provider) ├─ Functions/SendOtp.cs # HTTP trigger: POST /api/SendOtp ├─ Src/ -│ ├─ CyotEnvelope.cs # parse/validate the cleartext SAS → CYOT envelope -│ ├─ DeliveryContext.cs # JWE decrypt (RSA-OAEP-256 + A256GCM) → CyotDeliveryContext -│ ├─ DispatchEngine.cs # resolve provider → credential → endpoint → send → outcome +│ ├─ DispatchEngine.cs # envelope parse → JWE decrypt → provider dispatch │ ├─ ProviderRegistry.cs # keyed adapter registry + EPP_PROVIDER_NAME resolution │ ├─ IProviderAdapter.cs # Manifest + BuildRequest + ParseResponse │ ├─ Providers/*.cs # infobip, telesign, soprano, sinch diff --git a/dotnet/Src/CyotEnvelope.cs b/dotnet/Src/CyotEnvelope.cs deleted file mode 100644 index 8e25e03..0000000 --- a/dotnet/Src/CyotEnvelope.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Text.Json; - -namespace Cyot.Otp; - -// The cleartext SAS → CYOT routing envelope (SendCyotOtpRequest). PII lives in the encrypted JWE. -public sealed record CyotEnvelope( - string? Type, - string? TenantId, - string? CorrelationId, - int Channel, - int Mode, - int? TtlSeconds, - string EncryptedDeliveryContext); - -// Parses + validates the cleartext envelope (see docs/CONTRACT.md §1). -public static class EnvelopeParser -{ - public const int ModeLive = 1; - public const int ModeEvaluation = 2; - - private static readonly Dictionary ChannelByCode = new() { [1] = "sms", [2] = "voice" }; - private static readonly Dictionary ChannelByName = new(StringComparer.OrdinalIgnoreCase) { ["sms"] = 1, ["voice"] = 2 }; - private static readonly Dictionary ModeByName = new(StringComparer.OrdinalIgnoreCase) { ["live"] = ModeLive, ["evaluation"] = ModeEvaluation }; - - public static string? ChannelName(int code) => ChannelByCode.TryGetValue(code, out var name) ? name : null; - - public static (CyotEnvelope? Envelope, string? Error) Parse(JsonElement payload) - { - if (payload.ValueKind != JsonValueKind.Object) - return (null, "invalid envelope"); - - string? String(string name) => - payload.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; - int? Int(string name) => - payload.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null; - - // channel/mode accept the int enum (1/2) or the string form ("sms"/"voice", "live"/"evaluation"). - int? Channel() - { - var code = Int("channel"); - if (code is not null) return ChannelByCode.ContainsKey(code.Value) ? code : null; - var name = String("channel"); - return name is not null && ChannelByName.TryGetValue(name, out var mapped) ? mapped : null; - } - int? Mode() - { - var code = Int("mode"); - if (code is not null) return code is ModeLive or ModeEvaluation ? code : null; - var name = String("mode"); - return name is not null && ModeByName.TryGetValue(name, out var mapped) ? mapped : null; - } - - var encrypted = String("encryptedDeliveryContext"); - if (string.IsNullOrEmpty(encrypted)) - return (null, "encryptedDeliveryContext is required"); - - var channel = Channel(); - if (channel is null) - return (null, "unsupported channel"); - - var mode = Mode(); - if (mode is null) - return (null, "unsupported mode"); - - return (new CyotEnvelope(String("type"), String("tenantId"), String("correlationId"), - channel.Value, mode.Value, Int("ttlSeconds"), encrypted), null); - } -} diff --git a/dotnet/Src/DeliveryContext.cs b/dotnet/Src/DeliveryContext.cs deleted file mode 100644 index afe3252..0000000 --- a/dotnet/Src/DeliveryContext.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Cyot.Otp; - -// Decrypted JWE plaintext (CyotDeliveryContext). Contains the PII: phone + the rendered message, -// which includes the passcode. Never logged. -public sealed class CyotDeliveryContext -{ - [JsonPropertyName("nonce")] public string? Nonce { get; set; } - [JsonPropertyName("phoneNumber")] public string? PhoneNumber { get; set; } - [JsonPropertyName("extension")] public string? Extension { get; set; } - [JsonPropertyName("locale")] public string? Locale { get; set; } - [JsonPropertyName("message")] public string? Message { get; set; } - [JsonPropertyName("riskContext")] public JsonElement? RiskContext { get; set; } -} - -// Result of a successful decrypt: the protected header (for kid/alg logging) plus the plaintext context. -public sealed record JweResult(string? Kid, string? Alg, string? Enc, CyotDeliveryContext Context); - -// Supplies the RSA private key for the JWE `kid`. Injectable so tests use a local key. -public interface IJweKeyProvider -{ - RSA GetPrivateKey(string? kid); -} - -// Decrypts the RSA-OAEP-256 + A256GCM JWE compact serialization to a CyotDeliveryContext. -public sealed class JweDecryptor -{ - private readonly IJweKeyProvider _keys; - - public JweDecryptor(IJweKeyProvider keys) => _keys = keys; - - // Reject oversized or structurally invalid JWEs before base64-decoding or allocating buffers. - private const int MaxJweLength = 16384; - - public JweResult Decrypt(string compactJwe) - { - AssertWellFormed(compactJwe); - var headers = Jose.JWT.Headers(compactJwe); - var kid = headers.TryGetValue("kid", out var kidValue) ? kidValue?.ToString() : null; - var alg = headers.TryGetValue("alg", out var algValue) ? algValue?.ToString() : null; - var enc = headers.TryGetValue("enc", out var encValue) ? encValue?.ToString() : null; - var rsa = _keys.GetPrivateKey(kid); - // Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). - var plaintext = Jose.JWT.Decrypt(compactJwe, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); - var context = JsonSerializer.Deserialize(plaintext) ?? new CyotDeliveryContext(); - return new JweResult(kid, alg, enc, context); - } - - // Contract: exactly five non-empty compact segments; alg/enc/IV/tag are enforced by the JWE decrypt. - private static void AssertWellFormed(string compactJwe) - { - if (string.IsNullOrEmpty(compactJwe)) - throw new InvalidOperationException("malformed JWE"); - if (compactJwe.Length > MaxJweLength) - throw new InvalidOperationException("delivery context exceeds size limit"); - var segments = compactJwe.Split('.'); - if (segments.Length != 5 || Array.Exists(segments, string.IsNullOrEmpty)) - throw new InvalidOperationException("malformed JWE: expected five non-empty segments"); - } -} - -// Key source: the EPP_DECRYPTION_KEY_PEM app setting, which is a Key Vault reference — the runtime only -// ever sees the resolved PEM. Imported once, because doing it per delivery would add an RSA import -// inside the response budget and turn a bad key into a failure on every call. -public sealed class EnvJweKeyProvider : IJweKeyProvider -{ - private readonly IEnv _env; - private RSA? _cached; - private string? _cachedPem; - - public EnvJweKeyProvider(IEnv env) => _env = env; - - public RSA GetPrivateKey(string? kid) - { - var pem = _env.Get("EPP_DECRYPTION_KEY_PEM"); - if (string.IsNullOrEmpty(pem)) - throw new InvalidOperationException("private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)"); - - if (_cached is not null && _cachedPem == pem) return _cached; - - var rsa = RSA.Create(); - rsa.ImportFromPem(NormalizePem(pem)); - _cached = rsa; - _cachedPem = pem; - return rsa; - } - - // The setup script stores the key as base64 over the PEM so its newlines survive being carried as a - // secret and then as an app setting, so accept either form. - private static string NormalizePem(string value) => - value.Contains("-----BEGIN", StringComparison.Ordinal) - ? value - : System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(value.Trim())); -} diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index 134a382..4895f38 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -1,11 +1,165 @@ +using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; -namespace Cyot.Otp; +namespace Epp.Otp; + +// The delivery pipeline, in request order: parse the cleartext SAS envelope, decrypt the JWE delivery +// context that carries the PII, then dispatch to the bound provider. Fail-closed. + +// The cleartext SAS routing envelope. PII lives in the encrypted JWE. +public sealed record Envelope( + string? Type, + string? TenantId, + string? CorrelationId, + int Channel, + int Mode, + int? TtlSeconds, + string EncryptedDeliveryContext); + +public static class EnvelopeParser +{ + public const int ModeLive = 1; + public const int ModeEvaluation = 2; + + private static readonly Dictionary ChannelByCode = new() { [1] = "sms", [2] = "voice" }; + private static readonly Dictionary ChannelByName = new(StringComparer.OrdinalIgnoreCase) { ["sms"] = 1, ["voice"] = 2 }; + private static readonly Dictionary ModeByName = new(StringComparer.OrdinalIgnoreCase) { ["live"] = ModeLive, ["evaluation"] = ModeEvaluation }; + + public static string? ChannelName(int code) => ChannelByCode.TryGetValue(code, out var name) ? name : null; + + public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) + return (null, "invalid envelope"); + + string? String(string name) => + payload.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + int? Int(string name) => + payload.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null; + + // channel/mode accept the int enum (1/2) or the string form ("sms"/"voice", "live"/"evaluation"). + int? Channel() + { + var code = Int("channel"); + if (code is not null) return ChannelByCode.ContainsKey(code.Value) ? code : null; + var name = String("channel"); + return name is not null && ChannelByName.TryGetValue(name, out var mapped) ? mapped : null; + } + int? Mode() + { + var code = Int("mode"); + if (code is not null) return code is ModeLive or ModeEvaluation ? code : null; + var name = String("mode"); + return name is not null && ModeByName.TryGetValue(name, out var mapped) ? mapped : null; + } + + var encrypted = String("encryptedDeliveryContext"); + if (string.IsNullOrEmpty(encrypted)) + return (null, "encryptedDeliveryContext is required"); + + var channel = Channel(); + if (channel is null) + return (null, "unsupported channel"); + + var mode = Mode(); + if (mode is null) + return (null, "unsupported mode"); + + return (new Envelope(String("type"), String("tenantId"), String("correlationId"), + channel.Value, mode.Value, Int("ttlSeconds"), encrypted), null); + } +} + +// Decrypted JWE plaintext. Contains the PII: phone + the rendered message, which includes the passcode. +public sealed class DeliveryContext +{ + [JsonPropertyName("nonce")] public string? Nonce { get; set; } + [JsonPropertyName("phoneNumber")] public string? PhoneNumber { get; set; } + [JsonPropertyName("extension")] public string? Extension { get; set; } + [JsonPropertyName("locale")] public string? Locale { get; set; } + [JsonPropertyName("message")] public string? Message { get; set; } + [JsonPropertyName("riskContext")] public JsonElement? RiskContext { get; set; } +} + +public sealed record JweResult(string? Kid, string? Alg, string? Enc, DeliveryContext Context); + +// Injectable so tests use a local key. +public interface IJweKeyProvider +{ + RSA GetPrivateKey(string? kid); +} + +public sealed class JweDecryptor +{ + private const int MaxJweLength = 16384; + private readonly IJweKeyProvider _keys; + + public JweDecryptor(IJweKeyProvider keys) => _keys = keys; + + public JweResult Decrypt(string compactJwe) + { + AssertWellFormed(compactJwe); + var headers = Jose.JWT.Headers(compactJwe); + var kid = headers.TryGetValue("kid", out var kidValue) ? kidValue?.ToString() : null; + var alg = headers.TryGetValue("alg", out var algValue) ? algValue?.ToString() : null; + var enc = headers.TryGetValue("enc", out var encValue) ? encValue?.ToString() : null; + var rsa = _keys.GetPrivateKey(kid); + // Pin alg/enc so a tampered header can't downgrade the crypto. + var plaintext = Jose.JWT.Decrypt(compactJwe, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); + var context = JsonSerializer.Deserialize(plaintext) ?? new DeliveryContext(); + return new JweResult(kid, alg, enc, context); + } + + private static void AssertWellFormed(string compactJwe) + { + // Reject oversized or malformed input before base64-decoding or allocating buffers. + if (string.IsNullOrEmpty(compactJwe)) + throw new InvalidOperationException("malformed JWE"); + if (compactJwe.Length > MaxJweLength) + throw new InvalidOperationException("delivery context exceeds size limit"); + var segments = compactJwe.Split('.'); + if (segments.Length != 5 || Array.Exists(segments, string.IsNullOrEmpty)) + throw new InvalidOperationException("malformed JWE: expected five non-empty segments"); + } +} + +// EPP_DECRYPTION_KEY_PEM is a Key Vault reference, so the runtime only ever sees the resolved PEM. +// Imported once, because doing it per delivery would add an RSA import inside the response budget and +// turn a bad key into a failure on every call. +public sealed class EnvJweKeyProvider : IJweKeyProvider +{ + private readonly IEnv _env; + private RSA? _cached; + private string? _cachedPem; + + public EnvJweKeyProvider(IEnv env) => _env = env; + + public RSA GetPrivateKey(string? kid) + { + var pem = _env.Get("EPP_DECRYPTION_KEY_PEM"); + if (string.IsNullOrEmpty(pem)) + throw new InvalidOperationException("private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)"); + + if (_cached is not null && _cachedPem == pem) return _cached; + + var rsa = RSA.Create(); + rsa.ImportFromPem(NormalizePem(pem)); + _cached = rsa; + _cachedPem = pem; + return rsa; + } + + // The setup script stores the key as base64 over the PEM so its newlines survive being carried as a + // secret and then as an app setting, so accept either form. + private static string NormalizePem(string value) => + value.Contains("-----BEGIN", StringComparison.Ordinal) + ? value + : Encoding.UTF8.GetString(Convert.FromBase64String(value.Trim())); +} -// Core engine: resolve provider -> credential (Key Vault) -> endpoint -> adapter builds request -> -// send with a timeout -> map status to outcome + HTTP status. Fail-closed. public sealed class DispatchEngine { private const int DefaultTimeoutMs = 1500; diff --git a/dotnet/Src/IProviderAdapter.cs b/dotnet/Src/IProviderAdapter.cs index 6b3d239..5d048f2 100644 --- a/dotnet/Src/IProviderAdapter.cs +++ b/dotnet/Src/IProviderAdapter.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Cyot.Otp; +namespace Epp.Otp; // A provider is one adapter: manifest (protocol facts) + build/parse. Onboarding = add one class. public interface IProviderAdapter diff --git a/dotnet/Src/ISecretResolver.cs b/dotnet/Src/ISecretResolver.cs index a924e6b..eab5801 100644 --- a/dotnet/Src/ISecretResolver.cs +++ b/dotnet/Src/ISecretResolver.cs @@ -1,4 +1,4 @@ -namespace Cyot.Otp; +namespace Epp.Otp; // Seam over Key Vault so the engine can be unit-tested with a fake. public interface ISecretResolver diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index cc925e8..36ab8bb 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Cyot.Otp; +namespace Epp.Otp; // Language-agnostic contract types (see /docs/CONTRACT.md). diff --git a/dotnet/Src/OutcomeMapper.cs b/dotnet/Src/OutcomeMapper.cs index be750df..cb99989 100644 --- a/dotnet/Src/OutcomeMapper.cs +++ b/dotnet/Src/OutcomeMapper.cs @@ -1,4 +1,4 @@ -namespace Cyot.Otp; +namespace Epp.Otp; // Maps a provider's parsed status to a normalized outcome, then to an HTTP status. Fail-closed: // an unknown/unmapped status is treated as Fail. diff --git a/dotnet/Src/ProviderRegistry.cs b/dotnet/Src/ProviderRegistry.cs index f5bb9a5..bba19e1 100644 --- a/dotnet/Src/ProviderRegistry.cs +++ b/dotnet/Src/ProviderRegistry.cs @@ -1,4 +1,4 @@ -namespace Cyot.Otp; +namespace Epp.Otp; // Registry of provider adapters (keyed by lowercased id), and resolution of the active provider: // the request's Provider, else the deployment's EPP_PROVIDER_NAME. One provider active per deployment. diff --git a/dotnet/Src/Providers/InfobipProvider.cs b/dotnet/Src/Providers/InfobipProvider.cs index 9e08f6e..e068a1a 100644 --- a/dotnet/Src/Providers/InfobipProvider.cs +++ b/dotnet/Src/Providers/InfobipProvider.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Cyot.Otp.Providers; +namespace Epp.Otp.Providers; // Infobip: SMS via /sms/3/messages, voice via /tts/3/advanced. Auth: App API key. public sealed class InfobipProvider : IProviderAdapter diff --git a/dotnet/Src/Providers/SinchProvider.cs b/dotnet/Src/Providers/SinchProvider.cs index df4db37..9e9376a 100644 --- a/dotnet/Src/Providers/SinchProvider.cs +++ b/dotnet/Src/Providers/SinchProvider.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Cyot.Otp.Providers; +namespace Epp.Otp.Providers; // Sinch: SMS via XMS Batches (POST /xms/v1/{plan}/batches, Bearer). Voice via Calling TTS callout. public sealed class SinchProvider : IProviderAdapter diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index f9ebe70..537789e 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Cyot.Otp.Providers; +namespace Epp.Otp.Providers; // Soprano Connect (MEMS): POST {base}/messages/{sms|voice}. Auth: X-MEMS-API-ID + X-MEMS-API-Key. public sealed class SopranoProvider : IProviderAdapter diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs index 2821aba..1f6f7a2 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -1,7 +1,7 @@ using System.Text; using System.Text.Json; -namespace Cyot.Otp.Providers; +namespace Epp.Otp.Providers; // Telesign: SMS via /v1/messaging, voice via /v1/voice (form-urlencoded). Auth: HTTP Basic (customer_id:api_key). public sealed class TelesignProvider : IProviderAdapter diff --git a/dotnet/Src/SecretResolver.cs b/dotnet/Src/SecretResolver.cs index 3baea42..3a97f65 100644 --- a/dotnet/Src/SecretResolver.cs +++ b/dotnet/Src/SecretResolver.cs @@ -2,7 +2,7 @@ using Azure.Identity; using Azure.Security.KeyVault.Secrets; -namespace Cyot.Otp; +namespace Epp.Otp; // Resolves Key Vault secret names to values via the Function's managed identity (user-assigned when // AZURE_CLIENT_ID is set, else system-assigned), cached briefly so rotations are picked up. diff --git a/dotnet/Src/TokenValidator.cs b/dotnet/Src/TokenValidator.cs index e64f9d8..1e9f7c9 100644 --- a/dotnet/Src/TokenValidator.cs +++ b/dotnet/Src/TokenValidator.cs @@ -3,7 +3,7 @@ using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; -namespace Cyot.Otp; +namespace Epp.Otp; // Validates the Entra JWT when REQUIRE_AUTH=true (aud=EXPECTED_AUDIENCE, issuer tenant=ISSUER_TENANT_ID, // RS256 via JWKS). No-op pass-through when REQUIRE_AUTH is not "true". diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 843bf30..8447a47 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; -using Cyot.Otp; -using Cyot.Otp.Providers; +using Epp.Otp; +using Epp.Otp.Providers; using Xunit; -namespace Cyot.Otp.Tests; +namespace Epp.Otp.Tests; // Conformance tests for the pure contract logic (see /docs/CONTRACT.md §6). public class ContractTests @@ -52,7 +52,7 @@ public void OutcomeMappingAndHttpStatus() [Fact] public void InfobipBuildsHttpsSmsRequestWithAppAuthAndCode() { - var env = new FakeEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "CYOT" }; + var env = new FakeEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "EPP" }; var req = new InfobipProvider().BuildRequest("sms", "https://api.infobip.com", Disp(message: "Use verification code 918273 for Microsoft authentication."), new ProviderCredential("apiKey", Secret: "ib"), env); diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 99060c4..2f33322 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -1,11 +1,11 @@ using System.Net; using System.Text; -using Cyot.Otp; -using Cyot.Otp.Providers; +using Epp.Otp; +using Epp.Otp.Providers; using Microsoft.Extensions.Logging; using Xunit; -namespace Cyot.Otp.Tests; +namespace Epp.Otp.Tests; // Engine-level conformance tests (CONTRACT.md §6) with a fake Key Vault, HTTP client, and env. public class EngineTests diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs index ea7f0a9..9baede5 100644 --- a/dotnet/tests/EnvelopeTests.cs +++ b/dotnet/tests/EnvelopeTests.cs @@ -1,9 +1,9 @@ using System.Security.Cryptography; using System.Text.Json; -using Cyot.Otp; +using Epp.Otp; using Xunit; -namespace Cyot.Otp.Tests; +namespace Epp.Otp.Tests; // Envelope validation + JWE decryption round-trip (see docs/CONTRACT.md §1, §6). public class EnvelopeTests diff --git a/dotnet/tests/Cyot.Otp.Tests.csproj b/dotnet/tests/Epp.Otp.Tests.csproj similarity index 100% rename from dotnet/tests/Cyot.Otp.Tests.csproj rename to dotnet/tests/Epp.Otp.Tests.csproj diff --git a/javascript/package.json b/javascript/package.json index 7b0ab25..289d84d 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -1,7 +1,7 @@ { - "name": "cyot-poc", + "name": "epp-otp-sample", "version": "1.0.0", - "description": "CYOT delivery endpoint - send OTP via Infobip, Telesign, Sinch, or Soprano", + "description": "External Phone Provider delivery endpoint - send OTP via Infobip, Telesign, Sinch, or Soprano", "main": "src/functions/*.js", "scripts": { "start": "func start", diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index 64e91c8..30ec2ea 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -17,8 +17,8 @@ const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('./config'); // ─── SAS envelope ──────────────────────────────────────────────────────────── -// SAS sends a cleartext routing envelope (SendCyotOtpRequest) whose PII (phone + rendered message, -// which contains the passcode) is encrypted in a JWE. +// The cleartext routing envelope (SendCyotOtpRequest on the wire) whose PII — phone + rendered +// message, which contains the passcode — is encrypted in a JWE. // CyotChannel: 1=Sms, 2=Voice (0=Undefined). CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 70bb92c..6372200 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -1,6 +1,6 @@ 'use strict'; -// Tests for the SendOtp HTTP handler — the SAS → CYOT envelope: validation, JWE decryption round-trip, +// Tests for the SendOtp HTTP handler — the SAS → EPP envelope: validation, JWE decryption round-trip, // the happy path (nonce echo), Evaluation mode, and auth rejection. Handlers are captured by stubbing // @azure/functions; the JWE is encrypted here with a throwaway RSA key that the handler decrypts via // EPP_DECRYPTION_KEY_PEM. diff --git a/python/README.md b/python/README.md index 58b2182..45ec00d 100644 --- a/python/README.md +++ b/python/README.md @@ -12,8 +12,7 @@ python/ ├─ function_app.py # HTTP trigger: POST /api/SendOtp (v2 model) ├─ requirements.txt ├─ src/ -│ ├─ cyot.py # envelope parse/validate + JWE decrypt + context → dispatch -│ ├─ dispatch.py # resolve provider → credential → endpoint → send → outcome +│ ├─ dispatch.py # envelope parse → JWE decrypt → provider dispatch │ ├─ registry.py # adapter registry + EPP_PROVIDER_NAME resolution │ ├─ providers/*.py # infobip, telesign, soprano, sinch (manifest + build/parse) │ ├─ secrets.py # Key Vault via managed identity (cached) diff --git a/python/function_app.py b/python/function_app.py index 19593cc..8d27ee4 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -15,14 +15,14 @@ import azure.functions as func -from src.cyot import ( +from src.dispatch import ( MODE_EVALUATION, + DispatchEngine, context_to_dispatch, decrypt_delivery_context, make_key_provider, parse_envelope, ) -from src.dispatch import DispatchEngine from src.providers.infobip import InfobipProvider from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider diff --git a/python/src/cyot.py b/python/src/cyot.py deleted file mode 100644 index d02a67f..0000000 --- a/python/src/cyot.py +++ /dev/null @@ -1,152 +0,0 @@ -"""SAS → CYOT envelope handling. SAS sends a cleartext routing envelope (SendCyotOtpRequest) whose PII -(phone + rendered message, which contains the passcode) is encrypted in a JWE. This module parses the -envelope, decrypts the JWE delivery context (RSA-OAEP-256 + A256GCM, key selected by the JOSE `kid`), -and maps the CyotDeliveryContext onto the dispatch engine's request shape.""" -import base64 -import json -import re - -from jwcrypto import jwe as jwe_module -from jwcrypto import jwk - -from .models import DispatchRequest - -# CyotChannel: 1=Sms, 2=Voice (0=Undefined). CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). -CHANNEL_BY_CODE = {1: "sms", 2: "voice"} -CHANNEL_BY_NAME = {"sms": 1, "voice": 2} -MODE_LIVE = 1 -MODE_EVALUATION = 2 -MODE_BY_NAME = {"live": MODE_LIVE, "evaluation": MODE_EVALUATION} - - -def _normalize_channel(channel): - """channel accepts the int enum (1/2) or the string form ('sms'/'voice').""" - if isinstance(channel, bool): - return None - if channel in CHANNEL_BY_CODE: - return channel - if isinstance(channel, str): - return CHANNEL_BY_NAME.get(channel.lower()) - return None - - -def _normalize_mode(mode): - """mode accepts the int enum (1/2) or the string form ('live'/'evaluation').""" - if isinstance(mode, bool): - return None - if mode in (MODE_LIVE, MODE_EVALUATION): - return mode - if isinstance(mode, str): - return MODE_BY_NAME.get(mode.lower()) - return None - - -def parse_envelope(payload): - """Validates + normalizes the cleartext envelope. Returns (envelope, None) or (None, error).""" - if not isinstance(payload, dict): - return None, "invalid envelope" - encrypted = payload.get("encryptedDeliveryContext") - if not isinstance(encrypted, str) or not encrypted: - return None, "encryptedDeliveryContext is required" - channel = _normalize_channel(payload.get("channel")) - if channel is None: - return None, f"unsupported channel '{payload.get('channel')}'" - mode = _normalize_mode(payload.get("mode")) - if mode is None: - return None, f"unsupported mode '{payload.get('mode')}'" - return { - "type": payload.get("type"), - "tenant_id": payload.get("tenantId"), - "correlation_id": payload.get("correlationId"), - "channel": channel, - "mode": mode, - "ttl_seconds": payload.get("ttlSeconds"), - "encrypted_delivery_context": encrypted, - }, None - - -def read_protected_header(compact_jwe): - """Reads the JWE protected (first) segment without decrypting, so kid/alg/enc can be logged.""" - header_segment = compact_jwe.split(".")[0] - header_segment += "=" * (-len(header_segment) % 4) - return json.loads(base64.urlsafe_b64decode(header_segment)) - - -def make_key_provider(env): - """Returns a key_provider(kid) -> PEM. The key comes from EPP_DECRYPTION_KEY_PEM, which is a Key - Vault reference, so the runtime only ever sees the resolved PEM.""" - def key_provider(_kid): - return env.get("EPP_DECRYPTION_KEY_PEM") or "" - - return key_provider - - -# Reject oversized or structurally invalid JWEs before base64-decoding or allocating buffers. -MAX_JWE_LENGTH = 16384 - - -def _assert_well_formed_jwe(compact_jwe): - """Contract: exactly five non-empty compact segments; alg/enc/IV/tag are enforced by the decrypt.""" - if not isinstance(compact_jwe, str) or not compact_jwe: - raise ValueError("malformed JWE") - if len(compact_jwe) > MAX_JWE_LENGTH: - raise ValueError("delivery context exceeds size limit") - segments = compact_jwe.split(".") - if len(segments) != 5 or not all(segments): - raise ValueError("malformed JWE: expected five non-empty segments") - - -# The key is imported once and reused: doing it per delivery would add an RSA import inside the response -# budget and turn a bad key into a failure on every call instead of one obvious first failure. -_key_cache = {} - - -def _normalize_pem(value): - """The setup script stores the key as base64 over the PEM so its newlines survive being carried as a - secret and then as an app setting, so accept either form.""" - text = value if isinstance(value, str) else value.decode("utf-8") - if "-----BEGIN" in text: - return text - return base64.b64decode(text).decode("utf-8") - - -def _load_private_key(pem): - if not pem: - raise ValueError("private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)") - cached = _key_cache.get(pem) - if cached is None: - cached = jwk.JWK.from_pem(_normalize_pem(pem).encode("utf-8")) - _key_cache.clear() - _key_cache[pem] = cached - return cached - - -def decrypt_delivery_context(compact_jwe, key_provider): - """key_provider(kid) -> PEM string. Returns (header, CyotDeliveryContext dict).""" - _assert_well_formed_jwe(compact_jwe) - header = read_protected_header(compact_jwe) - key = _load_private_key(key_provider(header.get("kid"))) - # Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). - token = jwe_module.JWE(algs=["RSA-OAEP-256", "A256GCM"]) - token.deserialize(compact_jwe, key=key) - return header, json.loads(token.payload.decode("utf-8")) - - -def _space_passcode_for_voice(message): - """Voice: left alone, a TTS engine reads 641895 as "six hundred forty-one thousand eight hundred - ninety-five", which no user can type. Spacing the digits makes it read them one at a time.""" - return re.sub(r"\b\d{4,8}\b", lambda m: " ".join(m.group(0)), message or "", count=1) - - -def context_to_dispatch(context, envelope, message_id): - """Maps the decrypted context + envelope onto the engine's dispatch shape. The message is - pre-rendered (already contains the passcode), so the fields mirror CyotDeliveryContext.""" - return DispatchRequest( - destination=context.get("phoneNumber"), - message=(_space_passcode_for_voice(context.get("message")) - if CHANNEL_BY_CODE[envelope["channel"]] == "voice" else context.get("message")), - channel=CHANNEL_BY_CODE[envelope["channel"]], - message_id=message_id, - correlation_id=envelope["correlation_id"], - locale=context.get("locale"), - ) diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 019ee2a..9beb2bb 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -1,14 +1,154 @@ -"""Core engine: resolve provider -> credential (Key Vault) -> endpoint -> adapter -builds the request -> send with a timeout -> map status to outcome + HTTP status. Fail-closed.""" +"""The delivery pipeline, in request order: parse the cleartext SAS envelope, decrypt the JWE delivery +context that carries the PII, then dispatch to the bound provider. Fail-closed.""" +import base64 +import json import os +import re import requests +from jwcrypto import jwe as jwe_module +from jwcrypto import jwk -from .models import CONTINUE +from .models import CONTINUE, DispatchRequest from .outcome import DEFAULT_CHANNELS, resolve_outcome, to_http_status DEFAULT_TIMEOUT_MS = 1500 +# Channel: 1=Sms, 2=Voice (0=Undefined). DeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). +CHANNEL_BY_CODE = {1: "sms", 2: "voice"} +CHANNEL_BY_NAME = {"sms": 1, "voice": 2} +MODE_LIVE = 1 +MODE_EVALUATION = 2 +MODE_BY_NAME = {"live": MODE_LIVE, "evaluation": MODE_EVALUATION} + +MAX_JWE_LENGTH = 16384 + + +def _normalize_channel(channel): + """Accepts the int enum (1/2) or the string form ('sms'/'voice').""" + if isinstance(channel, bool): + return None + if channel in CHANNEL_BY_CODE: + return channel + if isinstance(channel, str): + return CHANNEL_BY_NAME.get(channel.lower()) + return None + + +def _normalize_mode(mode): + """Accepts the int enum (1/2) or the string form ('live'/'evaluation').""" + if isinstance(mode, bool): + return None + if mode in (MODE_LIVE, MODE_EVALUATION): + return mode + if isinstance(mode, str): + return MODE_BY_NAME.get(mode.lower()) + return None + + +def parse_envelope(payload): + """Returns (envelope, None) or (None, error).""" + if not isinstance(payload, dict): + return None, "invalid envelope" + encrypted = payload.get("encryptedDeliveryContext") + if not isinstance(encrypted, str) or not encrypted: + return None, "encryptedDeliveryContext is required" + channel = _normalize_channel(payload.get("channel")) + if channel is None: + return None, f"unsupported channel '{payload.get('channel')}'" + mode = _normalize_mode(payload.get("mode")) + if mode is None: + return None, f"unsupported mode '{payload.get('mode')}'" + return { + "type": payload.get("type"), + "tenant_id": payload.get("tenantId"), + "correlation_id": payload.get("correlationId"), + "channel": channel, + "mode": mode, + "ttl_seconds": payload.get("ttlSeconds"), + "encrypted_delivery_context": encrypted, + }, None + + +def read_protected_header(compact_jwe): + """Reads the first segment without decrypting, so kid/alg/enc can be logged.""" + header_segment = compact_jwe.split(".")[0] + header_segment += "=" * (-len(header_segment) % 4) + return json.loads(base64.urlsafe_b64decode(header_segment)) + + +def make_key_provider(env): + def key_provider(_kid): + return env.get("EPP_DECRYPTION_KEY_PEM") or "" + + return key_provider + + +def _assert_well_formed_jwe(compact_jwe): + # Reject oversized or malformed input before base64-decoding or allocating buffers. + if not isinstance(compact_jwe, str) or not compact_jwe: + raise ValueError("malformed JWE") + if len(compact_jwe) > MAX_JWE_LENGTH: + raise ValueError("delivery context exceeds size limit") + segments = compact_jwe.split(".") + if len(segments) != 5 or not all(segments): + raise ValueError("malformed JWE: expected five non-empty segments") + + +# Imported once and reused: a per-delivery RSA import would land inside the response budget, and would +# turn a bad key into a failure on every call instead of one obvious first failure. +_key_cache = {} + + +def _normalize_pem(value): + """The setup script stores the key as base64 over the PEM so its newlines survive being carried as + a secret and then as an app setting, so accept either form.""" + text = value if isinstance(value, str) else value.decode("utf-8") + if "-----BEGIN" in text: + return text + return base64.b64decode(text).decode("utf-8") + + +def _load_private_key(pem): + if not pem: + raise ValueError("private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)") + cached = _key_cache.get(pem) + if cached is None: + cached = jwk.JWK.from_pem(_normalize_pem(pem).encode("utf-8")) + _key_cache.clear() + _key_cache[pem] = cached + return cached + + +def decrypt_delivery_context(compact_jwe, key_provider): + """key_provider(kid) -> PEM string. Returns (header, delivery context dict).""" + _assert_well_formed_jwe(compact_jwe) + header = read_protected_header(compact_jwe) + key = _load_private_key(key_provider(header.get("kid"))) + # Pin alg/enc so a tampered header can't downgrade the crypto. + token = jwe_module.JWE(algs=["RSA-OAEP-256", "A256GCM"]) + token.deserialize(compact_jwe, key=key) + return header, json.loads(token.payload.decode("utf-8")) + + +def _space_passcode_for_voice(message): + """A TTS engine reads 641895 as "six hundred forty-one thousand eight hundred ninety-five", which + no user can type. Spacing the digits makes it read them one at a time.""" + return re.sub(r"\b\d{4,8}\b", lambda m: " ".join(m.group(0)), message or "", count=1) + + +def context_to_dispatch(context, envelope, message_id): + """The message is pre-rendered and already contains the passcode, so there is no separate code.""" + return DispatchRequest( + destination=context.get("phoneNumber"), + message=(_space_passcode_for_voice(context.get("message")) + if CHANNEL_BY_CODE[envelope["channel"]] == "voice" else context.get("message")), + channel=CHANNEL_BY_CODE[envelope["channel"]], + message_id=message_id, + correlation_id=envelope["correlation_id"], + locale=context.get("locale"), + ) + class DispatchEngine: def __init__(self, registry, secrets, env=None): diff --git a/python/src/models.py b/python/src/models.py index b352185..a5ce3c4 100644 --- a/python/src/models.py +++ b/python/src/models.py @@ -1,4 +1,4 @@ -"""Shared contract types for the CYOT OTP engine (see /docs/CONTRACT.md).""" +"""Shared contract types for the EPP OTP engine (see /docs/CONTRACT.md).""" from dataclasses import dataclass # Outcomes (mirrors the other languages). diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index 783db2d..c824c0f 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -30,7 +30,7 @@ def test_outcome_and_http_status(): def test_infobip_builds_https_sms_request(): - env = {"INFOBIP_SENDER_ID": "CYOT"} + env = {"INFOBIP_SENDER_ID": "EPP"} request = InfobipProvider().build_request( "sms", "https://api.infobip.com", _dispatch(message="Use verification code 918273 for Microsoft authentication."), diff --git a/python/tests/test_cyot.py b/python/tests/test_envelope.py similarity index 99% rename from python/tests/test_cyot.py rename to python/tests/test_envelope.py index 15e36d2..091c704 100644 --- a/python/tests/test_cyot.py +++ b/python/tests/test_envelope.py @@ -3,7 +3,7 @@ from jwcrypto import jwe, jwk -from src.cyot import ( +from src.dispatch import ( context_to_dispatch, decrypt_delivery_context, parse_envelope, From e446c2245ededa8cf30e950e7db2f3cc1e968377 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 15:16:28 -0700 Subject: [PATCH 14/17] Trim comments that restate the code in dispatch.js Removes eight comments that described what the following line already says, keeping the ones that carry information the code cannot: the Key Vault role requirement, the fail-closed outcome rule, and the provider failure-class mapping. --- javascript/src/functions/dispatch.js | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index 30ec2ea..4dc8cd2 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -197,8 +197,7 @@ function getProvider(providerId) { return providerId ? providerRegistry.get(String(providerId).toLowerCase()) || null : null; } -// The request's Provider, else the deployment's EPP_PROVIDER_NAME (set by UX at provisioning). One -// provider is active per deployment — selection is config, not routing the endpoint performs. +// One provider is active per deployment — selection is config, not routing the endpoint performs. function resolveProvider(requestProvider) { const providerId = (requestProvider || process.env.EPP_PROVIDER_NAME || '').toLowerCase(); return getProvider(providerId); @@ -210,8 +209,7 @@ function resolveProvider(requestProvider) { let keyVaultSecretClient = null; const secretCache = new Map(); -// Key Vault is accessed via the Function's managed identity (user-assigned when AZURE_CLIENT_ID is set, -// else system-assigned). The identity needs the Key Vault Secrets User role on the vault. +// The identity needs the Key Vault Secrets User role on the vault. function createManagedIdentityCredential() { return process.env.AZURE_CLIENT_ID ? new ManagedIdentityCredential(process.env.AZURE_CLIENT_ID) @@ -225,7 +223,6 @@ function getKeyVaultSecretClient() { return keyVaultSecretClient; } -// Resolves a Key Vault secret name to its value (cached briefly so rotations are picked up). async function resolveSecretValue(keyVaultSecretName) { if (!keyVaultSecretName) { return ''; @@ -244,7 +241,7 @@ async function resolveSecretValue(keyVaultSecretName) { return secretValue; } -// Best-effort warm-up at startup so the first request doesn't pay the cold Key Vault cost. Never throws. +// Best-effort, so the first request doesn't pay the cold Key Vault cost. Never throws. async function warmUpSecretCache() { if (!process.env.KEY_VAULT_URL) { return; @@ -262,7 +259,6 @@ async function warmUpSecretCache() { } } -// Resolves the outbound credential from the manifest auth block: apiKey (secret [+identity]) or oauth2 (token). async function resolveProviderCredential(authConfiguration = {}, options = {}) { const authenticationMode = authConfiguration.mode || 'apiKey'; @@ -286,8 +282,7 @@ async function resolveProviderCredential(authConfiguration = {}, options = {}) { // ─── Outcome mapping ───────────────────────────────────────────────────────────── -// Translates the provider's parsed status into a normalized outcome. A recognized status wins; an -// unknown status is fail-closed; only a status-less response trusts the HTTP result. +// A recognized status wins; an unknown status is fail-closed; only a status-less response trusts HTTP. function resolveOutcome(manifest, parsedResponse) { const responseMapping = manifest.responseMapping || {}; const providerStatusKey = parsedResponse.providerStatusName || parsedResponse.providerStatusCode; @@ -297,8 +292,7 @@ function resolveOutcome(manifest, parsedResponse) { return parsedResponse.success ? OUTCOME.CONTINUE : (responseMapping.default || OUTCOME.FAIL); } -// Translates the outcome into the endpoint HTTP status. Continue 200, Block 403, StepUp 409; a Fail -// surfaces the provider's failure class — 429 rate-limit, 401 auth (its 401/403), 400 other 4xx, else 502. +// A Fail surfaces the provider's failure class — 429 rate-limit, 401 auth, 400 other 4xx, else 502. function outcomeToHttpStatus(outcome, providerHttpStatus) { switch (outcome) { case OUTCOME.CONTINUE: @@ -319,7 +313,7 @@ function outcomeToHttpStatus(outcome, providerHttpStatus) { // ─── Sending ─────────────────────────────────────────────────────────────────── -// POSTs the provider request with a hard timeout; throws a descriptive error on timeout/network failure. + async function fetchWithTimeout(providerRequest, timeoutMilliseconds) { const abortController = new AbortController(); let timedOut = false; From c74b4d91e76751081ec3b82769a4121bf776fb9d Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 15:22:00 -0700 Subject: [PATCH 15/17] Fold Python models, outcome and registry into dispatch, and drop five unused JavaScript exports models.py (18 lines), outcome.py (30) and registry.py (16) each had a single consumer and now live in dispatch.py, matching the JavaScript layout. Python src/ goes from 7 files to 4. dispatch.js exported readProtectedHeader, loadPrivateKey, CHANNEL_BY_CODE, resolveSecretValue and DEFAULTS; none were imported anywhere, so the module's public surface shrinks from 11 symbols to 6. --- javascript/src/functions/dispatch.js | 5 -- python/function_app.py | 1 + python/src/dispatch.py | 65 ++++++++++++++++++- python/src/models.py | 18 ------ python/src/outcome.py | 30 --------- python/src/registry.py | 16 ----- python/tests/test_contract.py | 13 +++- python/tests/test_cyot.py | 95 ++++++++++++++++++++++++++++ python/tests/test_engine.py | 4 +- 9 files changed, 169 insertions(+), 78 deletions(-) delete mode 100644 python/src/models.py delete mode 100644 python/src/outcome.py delete mode 100644 python/src/registry.py create mode 100644 python/tests/test_cyot.py diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index 4dc8cd2..bfad675 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -468,14 +468,9 @@ warmUpSecretCache(); module.exports = { parseEnvelope, - readProtectedHeader, - loadPrivateKey, decryptDeliveryContext, contextToDispatch, - CHANNEL_BY_CODE, MODE, dispatchOtp, getProvider, - resolveSecretValue, - DEFAULTS, }; diff --git a/python/function_app.py b/python/function_app.py index 8d27ee4..6eae54d 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -18,6 +18,7 @@ from src.dispatch import ( MODE_EVALUATION, DispatchEngine, + ProviderRegistry, context_to_dispatch, decrypt_delivery_context, make_key_provider, diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 9beb2bb..7fd031f 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -4,15 +4,74 @@ import json import os import re +from dataclasses import dataclass import requests from jwcrypto import jwe as jwe_module from jwcrypto import jwk -from .models import CONTINUE, DispatchRequest -from .outcome import DEFAULT_CHANNELS, resolve_outcome, to_http_status - DEFAULT_TIMEOUT_MS = 1500 +DEFAULT_CHANNELS = ["sms", "voice"] + +# Outcomes (mirrors the other languages). +CONTINUE = "Continue" +FAIL = "Fail" +BLOCK = "Block" +STEP_UP = "StepUp" + + +@dataclass +class DispatchRequest: + destination: str + message: str | None + channel: str + message_id: str + correlation_id: str | None + locale: str | None + + +def resolve_outcome(manifest, parsed): + """A recognized status wins; an unknown status is fail-closed; only a status-less response + trusts the HTTP result.""" + mapping = manifest["response_mapping"] + key = parsed.get("provider_status_name") or parsed.get("provider_status_code") + if key: + return mapping.get(key) or mapping.get("default", FAIL) + return CONTINUE if parsed.get("success") else mapping.get("default", FAIL) + + +def to_http_status(outcome, provider_http_status): + """A Fail surfaces the provider's failure class.""" + if outcome == CONTINUE: + return 200 + if outcome == BLOCK: + return 403 + if outcome == STEP_UP: + return 409 + if outcome == FAIL: + if provider_http_status == 429: + return 429 + if provider_http_status in (401, 403): + return 401 + if 400 <= provider_http_status < 500: + return 400 + return 502 + + +class ProviderRegistry: + """One provider is active per deployment — selection is config, not routing.""" + + def __init__(self, adapters): + self._by_id = {adapter.manifest["id"].lower(): adapter for adapter in adapters} + + def get(self, provider_id): + if not provider_id: + return None + return self._by_id.get(provider_id.lower()) + + def resolve(self, request_provider): + return self.get(request_provider or os.environ.get("EPP_PROVIDER_NAME")) + # Channel: 1=Sms, 2=Voice (0=Undefined). DeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). CHANNEL_BY_CODE = {1: "sms", 2: "voice"} diff --git a/python/src/models.py b/python/src/models.py deleted file mode 100644 index a5ce3c4..0000000 --- a/python/src/models.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Shared contract types for the EPP OTP engine (see /docs/CONTRACT.md).""" -from dataclasses import dataclass - -# Outcomes (mirrors the other languages). -CONTINUE = "Continue" -FAIL = "Fail" -BLOCK = "Block" -STEP_UP = "StepUp" - - -@dataclass -class DispatchRequest: - destination: str - message: str | None - channel: str - message_id: str - correlation_id: str | None - locale: str | None diff --git a/python/src/outcome.py b/python/src/outcome.py deleted file mode 100644 index 4ef1459..0000000 --- a/python/src/outcome.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Maps a provider status to an outcome, then to an HTTP status. Fail-closed.""" -from .models import CONTINUE, FAIL, BLOCK, STEP_UP - -DEFAULT_CHANNELS = ["sms", "voice"] - - -def resolve_outcome(manifest, parsed): - mapping = manifest["response_mapping"] - key = parsed.get("provider_status_name") or parsed.get("provider_status_code") - if key: - return mapping.get(key) or mapping.get("default", FAIL) - return CONTINUE if parsed.get("success") else mapping.get("default", FAIL) - - -def to_http_status(outcome, provider_http_status): - """Continue 200, Block 403, StepUp 409; a Fail surfaces the provider's failure class.""" - if outcome == CONTINUE: - return 200 - if outcome == BLOCK: - return 403 - if outcome == STEP_UP: - return 409 - if outcome == FAIL: - if provider_http_status == 429: - return 429 - if provider_http_status in (401, 403): - return 401 - if 400 <= provider_http_status < 500: - return 400 - return 502 diff --git a/python/src/registry.py b/python/src/registry.py deleted file mode 100644 index f27263b..0000000 --- a/python/src/registry.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Registry of provider adapters + resolution of the active provider.""" -import os - - -class ProviderRegistry: - def __init__(self, adapters): - self._by_id = {adapter.manifest["id"].lower(): adapter for adapter in adapters} - - def get(self, provider_id): - if not provider_id: - return None - return self._by_id.get(provider_id.lower()) - - def resolve(self, request_provider): - provider_id = request_provider or os.environ.get("EPP_PROVIDER_NAME") - return self.get(provider_id) diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index c824c0f..db1cb31 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -1,11 +1,18 @@ """Conformance tests for the pure contract logic (see /docs/CONTRACT.md §6).""" -from src.models import BLOCK, CONTINUE, FAIL, STEP_UP, DispatchRequest -from src.outcome import resolve_outcome, to_http_status +from src.dispatch import ( + BLOCK, + CONTINUE, + FAIL, + STEP_UP, + DispatchRequest, + ProviderRegistry, + resolve_outcome, + to_http_status, +) from src.providers.infobip import InfobipProvider from src.providers.telesign import TelesignProvider from src.providers.soprano import SopranoProvider from src.providers.sinch import SinchProvider -from src.registry import ProviderRegistry def _dispatch(channel="sms", message=None): diff --git a/python/tests/test_cyot.py b/python/tests/test_cyot.py new file mode 100644 index 0000000..091c704 --- /dev/null +++ b/python/tests/test_cyot.py @@ -0,0 +1,95 @@ +"""Envelope validation + JWE decryption round-trip (see docs/CONTRACT.md §1, §6).""" +import json + +from jwcrypto import jwe, jwk + +from src.dispatch import ( + context_to_dispatch, + decrypt_delivery_context, + parse_envelope, +) + +# Throwaway RSA key: encrypt here, decrypt via the module using the private PEM. +_KEY = jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") +_PRIVATE_PEM = _KEY.export_to_pem(private_key=True, password=None).decode("utf-8") + + +def _encrypt(context, kid="test-key"): + protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": kid} + token = jwe.JWE(json.dumps(context).encode("utf-8"), protected=json.dumps(protected)) + token.add_recipient(_KEY) + return token.serialize(compact=True) + + +def _key_provider(_kid): + return _PRIVATE_PEM + + +def _sample_context(): + return {"nonce": "nonce-1", "phoneNumber": "+14255551234", "message": "Your code is 123456", "locale": "en-US"} + + +def test_missing_encrypted_context_is_error(): + envelope, error = parse_envelope({"channel": 1, "mode": 1}) + assert envelope is None + assert "encryptedDeliveryContext" in error + + +def test_unsupported_channel_is_error(): + envelope, error = parse_envelope({"channel": 9, "mode": 1, "encryptedDeliveryContext": "x"}) + assert envelope is None + assert "channel" in error + + +def test_unsupported_mode_is_error(): + envelope, error = parse_envelope({"channel": 1, "mode": 5, "encryptedDeliveryContext": "x"}) + assert envelope is None + assert "mode" in error + + +def test_valid_envelope_parses(): + envelope, error = parse_envelope({ + "type": "microsoft.mfa.otpDeliver.v1", "tenantId": "t", "correlationId": "c", + "channel": 2, "mode": 1, "ttlSeconds": 60, "encryptedDeliveryContext": "x", + }) + assert error is None + assert envelope["channel"] == 2 + assert envelope["mode"] == 1 + + +def test_jwe_round_trips_to_delivery_context(): + compact = _encrypt(_sample_context()) + header, context = decrypt_delivery_context(compact, _key_provider) + assert header["kid"] == "test-key" + assert header["alg"] == "RSA-OAEP-256" + assert header["enc"] == "A256GCM" + assert context["nonce"] == "nonce-1" + assert context["phoneNumber"] == "+14255551234" + assert context["message"] == "Your code is 123456" + + +def test_context_to_dispatch_maps_fields(): + envelope, _ = parse_envelope({ + "correlationId": "corr-1", "channel": 2, "mode": 1, "encryptedDeliveryContext": "x", + }) + dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") + assert dispatch.destination == "+14255551234" + assert dispatch.channel == "voice" + assert dispatch.message_id == "msg-1" + assert dispatch.correlation_id == "corr-1" + # Voice must read the passcode digit by digit. + assert "1 2 3 4 5 6" in dispatch.message + + +def test_sms_message_is_left_intact(): + envelope, _ = parse_envelope({"channel": 1, "mode": 1, "encryptedDeliveryContext": "x"}) + dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") + assert dispatch.message == "Your code is 123456" + + +def test_base64_wrapped_key_is_accepted(): + """The setup script stores EPP_DECRYPTION_KEY_PEM as base64 over the PEM.""" + import base64 as _b64 + wrapped = _b64.b64encode(_PRIVATE_PEM.encode("utf-8")).decode("ascii") + _header, context = decrypt_delivery_context(_encrypt(_sample_context()), lambda _kid: wrapped) + assert context["nonce"] == "nonce-1" diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index b37de8e..f32a1ec 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -4,13 +4,11 @@ import pytest import src.dispatch as dispatch_module -from src.dispatch import DispatchEngine -from src.models import DispatchRequest +from src.dispatch import DispatchEngine, DispatchRequest, ProviderRegistry from src.providers.infobip import InfobipProvider from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider from src.providers.telesign import TelesignProvider -from src.registry import ProviderRegistry class FakeSecrets: From cff5813fb30cdc59c3617a4c92bc53a9cde820ff Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 15:22:31 -0700 Subject: [PATCH 16/17] Remove test_cyot.py, duplicated by a bad stash pop It was byte-identical to test_envelope.py, so the eight envelope tests were running twice. --- python/tests/test_cyot.py | 95 --------------------------------------- 1 file changed, 95 deletions(-) delete mode 100644 python/tests/test_cyot.py diff --git a/python/tests/test_cyot.py b/python/tests/test_cyot.py deleted file mode 100644 index 091c704..0000000 --- a/python/tests/test_cyot.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Envelope validation + JWE decryption round-trip (see docs/CONTRACT.md §1, §6).""" -import json - -from jwcrypto import jwe, jwk - -from src.dispatch import ( - context_to_dispatch, - decrypt_delivery_context, - parse_envelope, -) - -# Throwaway RSA key: encrypt here, decrypt via the module using the private PEM. -_KEY = jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") -_PRIVATE_PEM = _KEY.export_to_pem(private_key=True, password=None).decode("utf-8") - - -def _encrypt(context, kid="test-key"): - protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": kid} - token = jwe.JWE(json.dumps(context).encode("utf-8"), protected=json.dumps(protected)) - token.add_recipient(_KEY) - return token.serialize(compact=True) - - -def _key_provider(_kid): - return _PRIVATE_PEM - - -def _sample_context(): - return {"nonce": "nonce-1", "phoneNumber": "+14255551234", "message": "Your code is 123456", "locale": "en-US"} - - -def test_missing_encrypted_context_is_error(): - envelope, error = parse_envelope({"channel": 1, "mode": 1}) - assert envelope is None - assert "encryptedDeliveryContext" in error - - -def test_unsupported_channel_is_error(): - envelope, error = parse_envelope({"channel": 9, "mode": 1, "encryptedDeliveryContext": "x"}) - assert envelope is None - assert "channel" in error - - -def test_unsupported_mode_is_error(): - envelope, error = parse_envelope({"channel": 1, "mode": 5, "encryptedDeliveryContext": "x"}) - assert envelope is None - assert "mode" in error - - -def test_valid_envelope_parses(): - envelope, error = parse_envelope({ - "type": "microsoft.mfa.otpDeliver.v1", "tenantId": "t", "correlationId": "c", - "channel": 2, "mode": 1, "ttlSeconds": 60, "encryptedDeliveryContext": "x", - }) - assert error is None - assert envelope["channel"] == 2 - assert envelope["mode"] == 1 - - -def test_jwe_round_trips_to_delivery_context(): - compact = _encrypt(_sample_context()) - header, context = decrypt_delivery_context(compact, _key_provider) - assert header["kid"] == "test-key" - assert header["alg"] == "RSA-OAEP-256" - assert header["enc"] == "A256GCM" - assert context["nonce"] == "nonce-1" - assert context["phoneNumber"] == "+14255551234" - assert context["message"] == "Your code is 123456" - - -def test_context_to_dispatch_maps_fields(): - envelope, _ = parse_envelope({ - "correlationId": "corr-1", "channel": 2, "mode": 1, "encryptedDeliveryContext": "x", - }) - dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") - assert dispatch.destination == "+14255551234" - assert dispatch.channel == "voice" - assert dispatch.message_id == "msg-1" - assert dispatch.correlation_id == "corr-1" - # Voice must read the passcode digit by digit. - assert "1 2 3 4 5 6" in dispatch.message - - -def test_sms_message_is_left_intact(): - envelope, _ = parse_envelope({"channel": 1, "mode": 1, "encryptedDeliveryContext": "x"}) - dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") - assert dispatch.message == "Your code is 123456" - - -def test_base64_wrapped_key_is_accepted(): - """The setup script stores EPP_DECRYPTION_KEY_PEM as base64 over the PEM.""" - import base64 as _b64 - wrapped = _b64.b64encode(_PRIVATE_PEM.encode("utf-8")).decode("ascii") - _header, context = decrypt_delivery_context(_encrypt(_sample_context()), lambda _kid: wrapped) - assert context["nonce"] == "nonce-1" From 13a69acdf6f321be796335f18dfc52cf50d7bd6e Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 21 Aug 2026 16:02:28 -0700 Subject: [PATCH 17/17] Fix Python startup blocker, trim dead code and redundant tests, recommend Easy Auth on function_app.py imported ProviderRegistry a second time from src.registry, a module that does not exist, so the Python Function App raised ModuleNotFoundError at load and could not start. Nothing caught it: no test imported the trigger module. Add python/tests/test_function_app.py covering the trigger itself (route registration, invalid JSON, nonce echo, Evaluation mode). Verified it fails on the original bug by reintroducing the bad import. Also: - Remove the Key Vault warm-up (JS only; .NET and Python never had it) and take the cold start for now. - Drop the channel/text arguments passed to parseResponse that no adapter reads; JS now matches CONTRACT.md and the .NET/Python signatures. - Stop passing tenantId/requestProvider into dispatchOtp from SendOtp: both re-read EPP_PROVIDER_NAME, and .NET/Python already pass null there. - Export resolveOutcome/outcomeToHttpStatus and assert the mapping directly instead of once per case through the whole pipeline, mirroring the .NET and Python contract tests. JS 42 -> 37 tests. - Reduce comments to one-line rationale across all three languages. - Document Easy Auth ON as the primary gate (the trigger is authLevel: anonymous) with EPP_REQUIRE_AUTH=true as the backstop, since Easy Auth is configured outside the code. --- README.md | 16 ++- docs/CONTRACT.md | 8 +- docs/ONBOARDING.md | 22 +++- docs/local.settings.sample.json | 1 + dotnet/Functions/SendOtp.cs | 16 +-- dotnet/Src/DispatchEngine.cs | 18 +-- dotnet/Src/Models.cs | 2 +- dotnet/Src/OutcomeMapper.cs | 4 +- dotnet/Src/ProviderRegistry.cs | 3 +- dotnet/Src/TokenValidator.cs | 7 +- javascript/README.md | 12 +- javascript/src/functions/SendOtp.js | 28 ++-- javascript/src/functions/config.js | 8 +- javascript/src/functions/dispatch.js | 122 +++++------------ javascript/src/functions/providers/infobip.js | 5 +- javascript/src/functions/providers/sinch.js | 8 +- javascript/src/functions/providers/soprano.js | 13 +- .../src/functions/providers/telesign.js | 6 +- javascript/src/functions/security.js | 13 +- javascript/test/auth.test.js | 25 ++-- javascript/test/dispatch.test.js | 99 +++++++------- javascript/test/security.test.js | 44 ------- python/function_app.py | 15 +-- python/src/dispatch.py | 22 ++-- python/src/security.py | 6 +- python/tests/test_function_app.py | 124 ++++++++++++++++++ 26 files changed, 314 insertions(+), 333 deletions(-) delete mode 100644 javascript/test/security.test.js create mode 100644 python/tests/test_function_app.py diff --git a/README.md b/README.md index 7d8c70e..524eefc 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,17 @@ See [`docs/CONTRACT.md`](docs/CONTRACT.md) for the full specification every impl ## Security -Set **`EPP_REQUIRE_AUTH=true`** in any real deployment. The Function then validates the caller's **Entra -JWT** (audience = `EPP_EXPECTED_AUDIENCE`, issuer tenant = `EPP_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 `EPP_REQUIRE_AUTH=false`. See -[docs/ONBOARDING.md §6](docs/ONBOARDING.md) for how to test it with a token. +**Turn Easy Auth (App Service Authentication) ON — that is the primary gate.** Set +`unauthenticatedClientAction` to `Return401` and pin `allowedApplications` to Microsoft's app id. The +HTTP trigger is `authLevel: anonymous`, so with Easy Auth off nothing stands in front of the endpoint. + +**Also set `EPP_REQUIRE_AUTH=true` in any real deployment.** Easy Auth lives outside the code, so a +portal change or slot swap can drop it silently; in-process validation is the backstop. The Function +then validates the caller's **Entra JWT** (audience = `EPP_EXPECTED_AUDIENCE`, issuer tenant = +`EPP_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 +(`func start`) there is no Easy Auth, so `EPP_REQUIRE_AUTH` is the only gate. See +[docs/ONBOARDING.md §5](docs/ONBOARDING.md) for how to test it with a token. ## Docs diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index f7f40f8..233e3f2 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -122,7 +122,7 @@ Set by provisioning. **Identical names across all languages.** | `EPP_PROVIDER_TIMEOUT_MS` | outbound call timeout (default 1500) | | `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 | | `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | -| `EPP_REQUIRE_AUTH` | `true` → validate the Entra token in-process (Easy Auth is the primary gate) | +| `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 | | `EPP_EXPECTED_AUDIENCE` | v1 token `aud` — the identifier URI `api://{host}/{appId}` | | `EPP_EXPECTED_ISSUER` | v1 issuer `https://sts.windows.net/{tenantId}/` | | `EPP_TENANT_ID` | your Entra tenant id | @@ -146,8 +146,10 @@ User* role). Never in code or config. appear only in the outbound provider request, which is the delivery itself). The single exception is `EPP_LOG_PLAINTEXT=true`, a **diagnostics-only** switch that logs the phone number, message, and passcode. It defaults to false and **must not be enabled in production**. -- **Auth** — Easy Auth is the primary gate; `EPP_EXPECTED_CLIENT_ID` mismatches return `403`. When - `EPP_REQUIRE_AUTH=true`, also validate the Entra JWT in-process (audience = `EPP_EXPECTED_AUDIENCE`, +- **Auth** — **Easy Auth must be ON** (`unauthenticatedClientAction=Return401`, `allowedApplications` + pinned to Microsoft's app); the trigger is `authLevel: anonymous`, so it is the primary gate. + `EPP_EXPECTED_CLIENT_ID` mismatches return `403`. Deployments should **also** set + `EPP_REQUIRE_AUTH=true` to validate the Entra JWT in-process (audience = `EPP_EXPECTED_AUDIENCE`, issuer tenant = `EPP_TENANT_ID`, RS256, JWKS). No-op pass-through when false (local dev). --- diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 292f897..51817da 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -29,13 +29,23 @@ in the encrypted JWE — see [CONTRACT.md](CONTRACT.md)). A **`200`** with the e (`{ "nonce": "", "correlationId": "", "providerStatus": "accepted" }`) means the provider **queued** it — delivery is asynchronous, so confirm via the provider's delivery report. -## 5. Secure it — Easy Auth +## 5. Secure it — turn Easy Auth ON -App Service Authentication is the gate: set `unauthenticatedClientAction` to `Return401` and list -Microsoft's application in `allowedApplications`, and anything else is rejected before your code runs. -Where Easy Auth is not available, set **`EPP_REQUIRE_AUTH=true`** (plus `EPP_EXPECTED_AUDIENCE` and -`EPP_TENANT_ID`) to validate the **Entra JWT** in-process instead. To test, obtain a token for the -expected audience and confirm: no token → 401, valid token → 200. +**Enable App Service Authentication (Easy Auth) on the Function App. This is the recommended posture and +the primary gate** — the trigger itself is `authLevel: anonymous`, so with Easy Auth off the endpoint is +open to the internet. Configure: + +- `unauthenticatedClientAction` = **`Return401`** +- `allowedApplications` = Microsoft's CYOT application id (`EPP_EXPECTED_CLIENT_ID`) + +Anything else is then rejected before your code runs. + +**Also set `EPP_REQUIRE_AUTH=true`.** Easy Auth is configured outside the code, so a portal change, slot +swap, or redeploy can silently drop it and nothing in the app would notice. In-process validation of the +**Entra JWT** (plus `EPP_EXPECTED_AUDIENCE` and `EPP_TENANT_ID`) is the backstop that fails closed if that +happens, and it is the only auth available when running locally with `func start`. + +To test, obtain a token for the expected audience and confirm: no token → 401, valid token → 200. ## 6. Deploy diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index 7367c72..2181368 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -7,6 +7,7 @@ "EPP_DECRYPTION_KEY_PEM": "", "EPP_ENCRYPTION_KEY_ID": "", + "_comment_auth": "Enable Easy Auth on the Function App (unauthenticatedClientAction=Return401, allowedApplications pinned to EPP_EXPECTED_CLIENT_ID) AND set EPP_REQUIRE_AUTH=true in any real deployment. false is for local func start only.", "EPP_REQUIRE_AUTH": "false", "EPP_EXPECTED_AUDIENCE": "api:///", "EPP_EXPECTED_CLIENT_ID": "25ec60fa-f18d-41a4-b398-50044c90ce13", diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index d7ab7ae..c201a67 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -8,11 +8,9 @@ namespace Epp.Otp; // HTTP trigger: POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the -// caller, parses the cleartext routing envelope, decrypts the JWE delivery context (PII lives there), -// dispatches to the provider, and echoes the nonce to prove decryption. -// -// Every line starts with [EPP], so a whole delivery can be pulled out of a noisy log with one filter: -// Application Insights : traces | where message startswith "[EPP]" | order by timestamp asc +// caller, parses the cleartext routing envelope, decrypts the JWE delivery context, dispatches to the +// provider, and echoes the nonce to prove decryption. Every line is tagged [EPP] so one filter pulls a +// whole delivery. public sealed class SendOtp { private const string Tag = "[EPP]"; @@ -32,7 +30,7 @@ public SendOtp(DispatchEngine engine, TokenValidator tokens, JweDecryptor decryp _log = log; } - // Easy Auth has already validated the token; this only records which identity actually arrived. + // Easy Auth has already validated the token; this records which identity arrived. private static string? ReadCallerAppId(HttpRequest req) { var encoded = req.Headers["x-ms-client-principal"].FirstOrDefault(); @@ -56,8 +54,7 @@ public SendOtp(DispatchEngine engine, TokenValidator tokens, JweDecryptor decryp } } - // Voice: left alone, a TTS engine reads 641895 as "six hundred forty-one thousand eight hundred - // ninety-five", which no user can type. Spacing the digits makes it read them one at a time. + // Left alone, TTS reads 641895 as "six hundred forty-one thousand...", which no user can type. private static string? SpacePasscodeForVoice(string? message) => string.IsNullOrEmpty(message) ? message : Regex.Replace(message, @"\b\d{4,8}\b", m => string.Join(" ", m.Value.ToCharArray())); @@ -126,8 +123,7 @@ public async Task Run( correlationId = envelope.CorrelationId ?? headerCorrelationId ?? requestId; - // Surfaced rather than swallowed: the passcode expires before it can be used, so delivering - // it would only produce a failed sign-in and a support call. + // Surfaced rather than swallowed: the passcode expires before it can be used. if (envelope.TtlSeconds is <= 0) _log.LogWarning("{Tag} ttlSeconds is {Ttl}; the passcode has expired.", Tag, envelope.TtlSeconds); diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index 4895f38..ea1d6a5 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -6,10 +6,9 @@ namespace Epp.Otp; -// The delivery pipeline, in request order: parse the cleartext SAS envelope, decrypt the JWE delivery -// context that carries the PII, then dispatch to the bound provider. Fail-closed. +// Delivery pipeline: parse the cleartext SAS envelope, decrypt the JWE that carries the PII, then +// dispatch to the configured provider. Fail-closed — only a Continue outcome is "accepted". -// The cleartext SAS routing envelope. PII lives in the encrypted JWE. public sealed record Envelope( string? Type, string? TenantId, @@ -40,7 +39,6 @@ public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) int? Int(string name) => payload.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null; - // channel/mode accept the int enum (1/2) or the string form ("sms"/"voice", "live"/"evaluation"). int? Channel() { var code = Int("channel"); @@ -73,7 +71,7 @@ public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) } } -// Decrypted JWE plaintext. Contains the PII: phone + the rendered message, which includes the passcode. +// Decrypted JWE plaintext: phone + the rendered message, which includes the passcode. public sealed class DeliveryContext { [JsonPropertyName("nonce")] public string? Nonce { get; set; } @@ -115,7 +113,7 @@ public JweResult Decrypt(string compactJwe) private static void AssertWellFormed(string compactJwe) { - // Reject oversized or malformed input before base64-decoding or allocating buffers. + // Reject oversized or malformed input before decoding or allocating buffers. if (string.IsNullOrEmpty(compactJwe)) throw new InvalidOperationException("malformed JWE"); if (compactJwe.Length > MaxJweLength) @@ -126,9 +124,7 @@ private static void AssertWellFormed(string compactJwe) } } -// EPP_DECRYPTION_KEY_PEM is a Key Vault reference, so the runtime only ever sees the resolved PEM. -// Imported once, because doing it per delivery would add an RSA import inside the response budget and -// turn a bad key into a failure on every call. +// Imported once: a per-delivery RSA import would sit inside the response budget. public sealed class EnvJweKeyProvider : IJweKeyProvider { private readonly IEnv _env; @@ -152,8 +148,8 @@ public RSA GetPrivateKey(string? kid) return rsa; } - // The setup script stores the key as base64 over the PEM so its newlines survive being carried as a - // secret and then as an app setting, so accept either form. + // The setup script stores the key as base64 over the PEM so its newlines survive being carried as + // an app setting, so accept either form. private static string NormalizePem(string value) => value.Contains("-----BEGIN", StringComparison.Ordinal) ? value diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index 36ab8bb..6f7ed7a 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -32,7 +32,7 @@ public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDicti public sealed record DispatchResult(int HttpStatus, object Body); -// The env snapshot passed to adapters (mirrors the JS `env` argument). +// The env snapshot passed to adapters. public interface IEnv { string? Get(string key); } public sealed class ProcessEnv : IEnv diff --git a/dotnet/Src/OutcomeMapper.cs b/dotnet/Src/OutcomeMapper.cs index cb99989..174501d 100644 --- a/dotnet/Src/OutcomeMapper.cs +++ b/dotnet/Src/OutcomeMapper.cs @@ -1,7 +1,6 @@ namespace Epp.Otp; -// Maps a provider's parsed status to a normalized outcome, then to an HTTP status. Fail-closed: -// an unknown/unmapped status is treated as Fail. +// Maps a provider status to an outcome, then to an HTTP status. Fail-closed: unknown status is Fail. public static class OutcomeMapper { public static readonly string[] DefaultChannels = { "sms", "voice" }; @@ -18,7 +17,6 @@ public static Outcome ResolveOutcome(ProviderManifest manifest, ParsedResponse p return manifest.ResponseMapping.TryGetValue("default", out var fallbackOutcome) ? fallbackOutcome : Outcome.Fail; } - // Continue 200, Block 403, StepUp 409; a Fail surfaces the provider's failure class. public static int ToHttpStatus(Outcome outcome, int providerHttpStatus) => outcome switch { Outcome.Continue => 200, diff --git a/dotnet/Src/ProviderRegistry.cs b/dotnet/Src/ProviderRegistry.cs index bba19e1..f6fc314 100644 --- a/dotnet/Src/ProviderRegistry.cs +++ b/dotnet/Src/ProviderRegistry.cs @@ -1,7 +1,6 @@ namespace Epp.Otp; -// Registry of provider adapters (keyed by lowercased id), and resolution of the active provider: -// the request's Provider, else the deployment's EPP_PROVIDER_NAME. One provider active per deployment. +// One provider is active per deployment; requestProvider is a test override. public sealed class ProviderRegistry { private readonly IReadOnlyDictionary _byId; diff --git a/dotnet/Src/TokenValidator.cs b/dotnet/Src/TokenValidator.cs index 1e9f7c9..a8f43c5 100644 --- a/dotnet/Src/TokenValidator.cs +++ b/dotnet/Src/TokenValidator.cs @@ -5,8 +5,8 @@ namespace Epp.Otp; -// Validates the Entra JWT when REQUIRE_AUTH=true (aud=EXPECTED_AUDIENCE, issuer tenant=ISSUER_TENANT_ID, -// RS256 via JWKS). No-op pass-through when REQUIRE_AUTH is not "true". +// Validates the Entra JWT when EPP_REQUIRE_AUTH=true (aud/issuer/JWKS, RS256). No-op pass-through +// otherwise — Easy Auth is the primary gate; this is the backstop. public sealed class TokenValidator { private readonly JwtSecurityTokenHandler _handler = new(); @@ -15,8 +15,7 @@ public sealed class TokenValidator public TokenValidator(IEnv? env = null) => _env = env ?? new ProcessEnv(); - // Easy Auth's allowedApplications does this at the platform, but it is off in the standalone - // configuration, so without this any app in the tenant could reach the endpoint. + // azp is the v2 caller claim, appid the v1 one. public static bool IsExpectedCaller(string? callerAppId, string? expectedClientId) => string.IsNullOrEmpty(expectedClientId) || string.Equals(callerAppId, expectedClientId, StringComparison.OrdinalIgnoreCase); diff --git a/javascript/README.md b/javascript/README.md index ceb912e..ea6925c 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -9,9 +9,12 @@ provider (**Infobip**, **Telesign**, **Sinch**, or **Soprano**). - Provider secrets from **Azure Key Vault** (managed identity). - **Correlation id** propagated to the provider and echoed back. - **Shutter mode** — process the full path but do not send. -- **Optional in-process token validation** — off by default; enable with `EPP_REQUIRE_AUTH=true`. +- **Token validation** — Easy Auth is the primary gate; `EPP_REQUIRE_AUTH=true` adds an in-process backstop. -> Token validation exists but is **off by default** — enable it in any real deployment (`EPP_REQUIRE_AUTH=true`). +> **Turn Easy Auth ON.** The trigger is `authLevel: anonymous`, so App Service Authentication +> (`unauthenticatedClientAction=Return401`, `allowedApplications` pinned to Microsoft's app) is what +> keeps the endpoint closed. Set **`EPP_REQUIRE_AUTH=true`** as well in any real deployment — it is the +> backstop if Easy Auth is ever misconfigured, and the only gate when running locally. ## Deploy @@ -61,7 +64,7 @@ Key Vault and can be rotated there without a redeploy. | `EPP_DECRYPTION_KEY_PEM` | RSA private key PEM for JWE decryption — a **Key Vault reference** in Azure | | `EPP_ENCRYPTION_KEY_ID` | expected JOSE `kid`; a mismatch is logged, not fatal | | `EPP_EXPECTED_CLIENT_ID` | caller `appid`/`azp` to admit; Easy Auth returns `403`, in-process validation returns `401` | -| `EPP_REQUIRE_AUTH` | `true` to also validate the token in-process — enable in any real deployment | +| `EPP_REQUIRE_AUTH` | **set `true` in any real deployment** — validates the token in-process as a backstop to Easy Auth | | `EPP_EXPECTED_AUDIENCE` | token `aud` (this endpoint's app registration appId) — required when `EPP_REQUIRE_AUTH=true` | | `EPP_TENANT_ID` | customer tenant id for issuer/JWKS — required when `EPP_REQUIRE_AUTH=true` | | `EPP_EXPECTED_ISSUER` | optional; pins a single issuer instead of accepting both v1 and v2 | @@ -103,9 +106,8 @@ are all non-secret configuration; only the key/token **value** lives in Key Vaul | Key Vault secret `soprano-api-key` | API key (sent as the `X-MEMS-API-Key` header) | | Key Vault secret `soprano-api-id` | API ID (sent as the `X-MEMS-API-ID` header) | | `EPP_PROVIDER_ENDPOINT` | **required** — your MEMS API base `https:///cgpapi` (per-customer; no default) | -| `SOPRANO_SOURCE_ID` | provisioned source/sender endpoint id, app setting — Soprano requires a provisioned sender, sent as `endpoints:[{type,id}]` | +| `EPP_PROVIDER_ACCOUNT_NAME` | the provisioned source/sender endpoint id — Soprano requires a provisioned sender, so a **numeric** value is sent as `endpoints:[{type,id}]`; a non-numeric one falls back to a free-text `source` | | `SOPRANO_SOURCE_TYPE` | provisioned source endpoint type, app setting (optional; default `1`) | -| `SOPRANO_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | optional free-text sender, used only as a fallback when `SOPRANO_SOURCE_ID` is unset | > `EPP_PROVIDER_ENDPOINT` is the provider base URL for the one active provider (e.g. a sandbox host). diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index 392fbe6..7d30ebc 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -5,12 +5,8 @@ 'use strict'; // POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the caller, parses -// the cleartext routing envelope, decrypts the JWE delivery context (PII lives there), dispatches to the -// provider, and echoes the nonce to prove decryption. -// -// Every line starts with [EPP], so a whole delivery can be pulled out of a noisy log with one filter: -// Log stream : look for [EPP] -// Application Insights : traces | where message startswith "[EPP]" | order by timestamp asc +// the cleartext routing envelope, decrypts the JWE delivery context, dispatches to the provider, and +// echoes the nonce to prove decryption. Every line is tagged [EPP] so one filter pulls a whole delivery. const { app } = require('@azure/functions'); const crypto = require('crypto'); @@ -26,8 +22,7 @@ const { readConfig, missingSettings } = require('./config'); const TAG = '[EPP]'; -// Easy Auth has already validated the token by the time this runs; this only records which identity -// actually arrived, which is the first thing worth knowing when a delivery is refused. +// Easy Auth has already validated the token by the time this runs; this records which identity arrived. function readCallerAppId(request) { const encoded = request.headers.get('x-ms-client-principal'); if (!encoded) return undefined; @@ -47,14 +42,8 @@ let pendingDelivery = Promise.resolve(); const whenDelivered = () => pendingDelivery; // Microsoft allows 3.2 s for the whole call, so the provider is called after the response. -function deliverInBackground(dispatch, envelope, evaluation, config, context, requestId) { - pendingDelivery = dispatchOtp(dispatch, { - tenantId: envelope.tenantId, - requestProvider: config.provider.name || undefined, - shutter: evaluation, - context, - requestId, - }) +function deliverInBackground(dispatch, evaluation, context, requestId) { + pendingDelivery = dispatchOtp(dispatch, { shutter: evaluation, context, requestId }) .then(({ httpStatus, body }) => { context.log(`${TAG} provider result : httpStatus=${httpStatus} outcome=${body.outcome || 'n/a'} providerStatus=${body.providerStatus || 'n/a'} providerMessageId=${body.providerMessageId || 'n/a'}`); }) @@ -128,8 +117,7 @@ app.http('SendOtp', { const correlationId = envelope.correlationId || headerCorrelationId || requestId; - // Surfaced rather than swallowed: the passcode expires before it can be used, so delivering - // it would only produce a failed sign-in and a support call. + // Surfaced rather than swallowed: the passcode expires before it can be used. if (envelope.ttlSeconds !== undefined && envelope.ttlSeconds <= 0) { warn(`ttlSeconds is ${envelope.ttlSeconds}; the passcode has expired.`); } @@ -138,7 +126,7 @@ app.http('SendOtp', { let delivery; try { ({ header, context: delivery } = await decryptDeliveryContext( - envelope.encryptedDeliveryContext, { config })); + envelope.encryptedDeliveryContext, config)); } catch (decryptError) { error(`decryption failed: ${decryptError.message}`); return { status: 400, jsonBody: { error: 'decryption_failed', correlationId, requestId } }; @@ -169,7 +157,7 @@ app.http('SendOtp', { const evaluation = envelope.mode === MODE.EVALUATION; const dispatch = contextToDispatch(delivery, envelope, clientRequestId); - deliverInBackground(dispatch, envelope, evaluation, config, context, requestId); + deliverInBackground(dispatch, evaluation, context, requestId); // Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery // and Microsoft re-sends over its own telephony, so the user gets the code twice. diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js index a0c998b..79be0f2 100644 --- a/javascript/src/functions/config.js +++ b/javascript/src/functions/config.js @@ -4,12 +4,10 @@ 'use strict'; -// The settings the request handler itself needs. dispatch.js and security.js read their own settings -// directly, so each module stays readable on its own; EPP_DECRYPTION_KEY_PEM is a Key Vault reference, -// so the runtime only ever sees the resolved PEM and never calls Key Vault itself. +// Settings the request handler needs. dispatch.js and security.js read their own directly. -// Read per call so a settings change (or a test) is picked up without a cold start. -function readConfig(env = process.env) { +function readConfig() { + const env = process.env; return { decryptionKeyPem: env.EPP_DECRYPTION_KEY_PEM || '', expectedKeyId: env.EPP_ENCRYPTION_KEY_ID || '', diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index bfad675..61ba4ae 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -4,11 +4,8 @@ 'use strict'; -// The delivery pipeline, in request order: parse the cleartext SAS envelope, decrypt the JWE delivery -// context that carries the PII, then dispatch to the bound provider. Each provider is a manifest + -// adapter under ./providers/.js; dispatchOtp resolves the provider, sends via its adapter with a -// timeout, then maps the provider status to an outcome and an HTTP status. Fail-closed: only a -// Continue outcome is "accepted". +// Delivery pipeline: parse the cleartext SAS envelope, decrypt the JWE that carries the PII, then +// dispatch to the configured provider. Fail-closed — only a Continue outcome is "accepted". const crypto = require('crypto'); const { compactDecrypt } = require('jose'); @@ -16,11 +13,7 @@ const { ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('./config'); -// ─── SAS envelope ──────────────────────────────────────────────────────────── -// The cleartext routing envelope (SendCyotOtpRequest on the wire) whose PII — phone + rendered -// message, which contains the passcode — is encrypted in a JWE. - -// CyotChannel: 1=Sms, 2=Voice (0=Undefined). CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). +// CyotChannel: 1=Sms, 2=Voice. CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); const MODE = Object.freeze({ LIVE: 1, EVALUATION: 2 }); @@ -38,7 +31,6 @@ function normalizeMode(mode) { return null; } -// Validates + normalizes the cleartext envelope. Returns { envelope } or { error }. function parseEnvelope(payload) { if (!payload || typeof payload !== 'object') { return { error: 'invalid envelope' }; @@ -58,9 +50,8 @@ function parseEnvelope(payload) { return { envelope: { type, tenantId, correlationId, channel: channelCode, mode: modeCode, ttlSeconds, encryptedDeliveryContext } }; } -// ─── JWE delivery context ──────────────────────────────────────────────────── -// Reject oversized or structurally invalid JWEs before base64-decoding or allocating buffers. +// Reject oversized or structurally invalid JWEs before decoding or allocating buffers. const MAX_JWE_LENGTH = 16384; function assertWellFormedJwe(compactJwe) { @@ -76,19 +67,17 @@ function assertWellFormedJwe(compactJwe) { } } -// Reads the JWE protected (first) segment without decrypting, so kid/alg/enc can be logged. function readProtectedHeader(compactJwe) { const protectedSegment = String(compactJwe).split('.')[0] || ''; return JSON.parse(Buffer.from(protectedSegment, 'base64url').toString('utf8')); } -// The key is imported once and reused: doing it per delivery would add an RSA import inside the -// response budget and turn a bad key into a failure on every call instead of one obvious first failure. +// Imported once: a per-delivery RSA import would sit inside the response budget. let cachedKey; let cachedKeyPem; -// The setup script stores the key as base64 over the PEM so its newlines survive being carried as a -// secret and then as an app setting, so accept either form. +// The setup script stores the key as base64 over the PEM so its newlines survive being carried as an +// app setting, so accept either form. function normalizePem(value) { const text = String(value || ''); if (text.includes('-----BEGIN')) return text; @@ -108,14 +97,12 @@ function loadPrivateKey(pem) { } // Decrypts the JWE compact serialization. Returns the protected header (for kid/alg logging) alongside -// the CyotDeliveryContext. `keyProvider(kid)` is injectable so tests can supply a local key. -async function decryptDeliveryContext(compactJwe, options = {}) { - const config = options.config || readConfig(options.env || process.env); +// the CyotDeliveryContext. +async function decryptDeliveryContext(compactJwe, config = readConfig()) { assertWellFormedJwe(compactJwe); const header = readProtectedHeader(compactJwe); - const pem = options.keyProvider ? await options.keyProvider(header.kid) : config.decryptionKeyPem; - const privateKey = loadPrivateKey(pem); - // Pin alg/enc so a tampered header can't downgrade the crypto (contract: RSA-OAEP-256 + A256GCM). + const privateKey = loadPrivateKey(config.decryptionKeyPem); + // Pin alg/enc so a tampered header can't downgrade the crypto. const { plaintext } = await compactDecrypt(compactJwe, privateKey, { keyManagementAlgorithms: ['RSA-OAEP-256'], contentEncryptionAlgorithms: ['A256GCM'], @@ -123,14 +110,12 @@ async function decryptDeliveryContext(compactJwe, options = {}) { return { header, context: JSON.parse(Buffer.from(plaintext).toString('utf8')) }; } -// Voice: left alone, a TTS engine reads 641895 as "six hundred forty-one thousand eight hundred -// ninety-five", which no user can type. Spacing the digits makes it read them one at a time. +// Left alone, TTS reads 641895 as "six hundred forty-one thousand...", which no user can type. function spacePasscodeForVoice(message) { return String(message || '').replace(/\b\d{4,8}\b/, (digits) => digits.split('').join(' ')); } -// Maps the decrypted context + envelope onto the engine's dispatch shape. The message is pre-rendered -// (already contains the passcode), so there is no separate code — the fields mirror CyotDeliveryContext. +// The message is pre-rendered and already contains the passcode, so there is no separate code field. function contextToDispatch(context, envelope, messageId) { const channel = CHANNEL_BY_CODE[envelope.channel]; return { @@ -143,7 +128,6 @@ function contextToDispatch(context, envelope, messageId) { }; } -// ─── Shared constants ──────────────────────────────────────────────────────── const OUTCOME = Object.freeze({ CONTINUE: 'Continue', @@ -177,10 +161,7 @@ const DEFAULTS = Object.freeze({ const SECRET_CACHE_TIME_TO_LIVE_MILLISECONDS = 5 * 60 * 1000; // rotated secrets picked up within this window -// ─── Provider registry ─────────────────────────────────────────────────────── -// Each ./providers/.js exports { manifest, buildRequest, parseResponse }. Onboarding a provider is -// a new file plus one line here — static so a broken provider fails at load, not mid-request. - +// Onboarding a provider is a new file plus one line here — static, so a broken provider fails at load. const providerRegistry = new Map( [ require('./providers/infobip'), @@ -197,28 +178,22 @@ function getProvider(providerId) { return providerId ? providerRegistry.get(String(providerId).toLowerCase()) || null : null; } -// One provider is active per deployment — selection is config, not routing the endpoint performs. +// One provider is active per deployment; the argument is a test override. function resolveProvider(requestProvider) { - const providerId = (requestProvider || process.env.EPP_PROVIDER_NAME || '').toLowerCase(); - return getProvider(providerId); + return getProvider(requestProvider || process.env.EPP_PROVIDER_NAME); } -// ─── Provider credentials (Key Vault via managed identity) ──────────────────────── -// The manifest carries only the secret's *name*; the value is read just-in-time and never logged. - +// The manifest carries only the secret's name; the value is read just-in-time and never logged. let keyVaultSecretClient = null; const secretCache = new Map(); // The identity needs the Key Vault Secrets User role on the vault. -function createManagedIdentityCredential() { - return process.env.AZURE_CLIENT_ID - ? new ManagedIdentityCredential(process.env.AZURE_CLIENT_ID) - : new ManagedIdentityCredential(); -} - function getKeyVaultSecretClient() { if (!keyVaultSecretClient) { - keyVaultSecretClient = new SecretClient(process.env.KEY_VAULT_URL, createManagedIdentityCredential()); + const credential = process.env.AZURE_CLIENT_ID + ? new ManagedIdentityCredential(process.env.AZURE_CLIENT_ID) + : new ManagedIdentityCredential(); + keyVaultSecretClient = new SecretClient(process.env.KEY_VAULT_URL, credential); } return keyVaultSecretClient; } @@ -241,33 +216,10 @@ async function resolveSecretValue(keyVaultSecretName) { return secretValue; } -// Best-effort, so the first request doesn't pay the cold Key Vault cost. Never throws. -async function warmUpSecretCache() { - if (!process.env.KEY_VAULT_URL) { - return; - } - const providerId = (process.env.EPP_PROVIDER_NAME || '').toLowerCase(); - const providerEntry = providerId && getProvider(providerId); - const keyVaultSecretName = providerEntry && providerEntry.manifest.auth && providerEntry.manifest.auth.keyVaultSecretName; - if (!keyVaultSecretName) { - return; - } - try { - await resolveSecretValue(keyVaultSecretName); - } catch { - // ignore - } -} - -async function resolveProviderCredential(authConfiguration = {}, options = {}) { - const authenticationMode = authConfiguration.mode || 'apiKey'; - - if (authenticationMode === 'oauth2') { - // oauth2 not wired end-to-end yet — no injected acquireProviderToken, so it fails closed. - let bearerToken = null; - if (typeof options.acquireProviderToken === 'function') { - bearerToken = await options.acquireProviderToken(options.channel); - } +async function resolveProviderCredential(authConfiguration = {}, acquireProviderToken) { + if ((authConfiguration.mode || 'apiKey') === 'oauth2') { + // oauth2 is not wired end-to-end yet: with no injected acquireProviderToken it fails closed. + const bearerToken = typeof acquireProviderToken === 'function' ? await acquireProviderToken() : null; return { mode: 'oauth2', token: bearerToken }; } @@ -280,8 +232,6 @@ async function resolveProviderCredential(authConfiguration = {}, options = {}) { return { mode: 'apiKey', secret, identity }; } -// ─── Outcome mapping ───────────────────────────────────────────────────────────── - // A recognized status wins; an unknown status is fail-closed; only a status-less response trusts HTTP. function resolveOutcome(manifest, parsedResponse) { const responseMapping = manifest.responseMapping || {}; @@ -292,7 +242,6 @@ function resolveOutcome(manifest, parsedResponse) { return parsedResponse.success ? OUTCOME.CONTINUE : (responseMapping.default || OUTCOME.FAIL); } -// A Fail surfaces the provider's failure class — 429 rate-limit, 401 auth, 400 other 4xx, else 502. function outcomeToHttpStatus(outcome, providerHttpStatus) { switch (outcome) { case OUTCOME.CONTINUE: @@ -311,8 +260,6 @@ function outcomeToHttpStatus(outcome, providerHttpStatus) { } } -// ─── Sending ─────────────────────────────────────────────────────────────────── - async function fetchWithTimeout(providerRequest, timeoutMilliseconds) { const abortController = new AbortController(); @@ -349,16 +296,15 @@ async function sendViaProvider(providerEntry, dispatch, options) { const providerId = manifest.id; const channel = (dispatch.channel || DEFAULTS.CHANNEL).toLowerCase(); - if (!(manifest.channels || DEFAULTS.CHANNELS).includes(channel)) { + if (!DEFAULTS.CHANNELS.includes(channel)) { writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} not supported`); return { httpStatus: HTTP_STATUS.BAD_REQUEST, body: errorBody(providerId, `channel '${channel}' not supported`, requestId) }; } - // Resolve the outbound credential; fail closed (502) if missing — this is our credential, not the - // caller's token. A declared identity secret is also required. + // Fail closed (502) if the credential is missing — this is our credential, not the caller's token. let credential = null; try { - credential = await resolveProviderCredential(manifest.auth, { channel, acquireProviderToken: options.acquireProviderToken }); + credential = await resolveProviderCredential(manifest.auth, options.acquireProviderToken); } catch (error) { writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} credential error=${error.message}`); } @@ -388,7 +334,6 @@ async function sendViaProvider(providerEntry, dispatch, options) { writeLog(`[DISPATCH] requestId=${requestId} provider=${providerId} channel=${channel} correlationId=${dispatch.correlationId} shutter=${!!shutter}`); - // Shutter mode: everything runs except the actual send. if (shutter) { writeLog(`[SHUTTER] requestId=${requestId} provider=${providerId} channel=${channel} processed but NOT sending`); return { @@ -397,7 +342,6 @@ async function sendViaProvider(providerEntry, dispatch, options) { }; } - // Endpoint timeout is a provisioned app setting (EPP_PROVIDER_TIMEOUT_MS). const timeoutMilliseconds = Number(process.env.EPP_PROVIDER_TIMEOUT_MS) || DEFAULTS.ENDPOINT_TIMEOUT_MILLISECONDS; let providerResponse; try { @@ -414,15 +358,13 @@ async function sendViaProvider(providerEntry, dispatch, options) { try { responseJson = JSON.parse(responseText); } catch { - // Non-JSON body (e.g. an HTML error page): keep it raw so the adapter's parseResponse still runs. + // Keep a non-JSON body raw so the adapter's parseResponse still runs. responseJson = { raw: responseText }; } const parsedResponse = adapter.parseResponse({ - channel, httpStatus: providerResponse.status, ok: providerResponse.ok, - text: responseText, json: responseJson, }); const outcome = resolveOutcome(manifest, parsedResponse); @@ -447,7 +389,6 @@ async function sendViaProvider(providerEntry, dispatch, options) { }; } -// Public entry: resolve the provider, then send. async function dispatchOtp(dispatch, options) { const { requestProvider, context, requestId } = options; const writeLog = (logMessage) => context && context.log(logMessage); @@ -463,9 +404,6 @@ async function dispatchOtp(dispatch, options) { return sendViaProvider(providerEntry, dispatch, options); } -// Fire-and-forget warm-up at module load. -warmUpSecretCache(); - module.exports = { parseEnvelope, decryptDeliveryContext, @@ -473,4 +411,6 @@ module.exports = { MODE, dispatchOtp, getProvider, + resolveOutcome, + outcomeToHttpStatus, }; diff --git a/javascript/src/functions/providers/infobip.js b/javascript/src/functions/providers/infobip.js index 468d646..ad7badc 100644 --- a/javascript/src/functions/providers/infobip.js +++ b/javascript/src/functions/providers/infobip.js @@ -4,13 +4,11 @@ 'use strict'; -// Infobip provider: SMS via /sms/3/messages, voice via /tts/3/advanced. -// Auth: App API key (default) or a Bearer token (oauth2 mode). Voice is unverified. +// Infobip: SMS /sms/3/messages, voice /tts/3/advanced (unverified). Auth: App API key, or Bearer in oauth2 mode. const manifest = { id: 'infobip', auth: { mode: 'apiKey', keyVaultSecretName: 'infobip-api-key' }, - // Infobip status groups (groupName): ACCEPTED/PENDING/DELIVERED = success, REJECTED/EXPIRED/UNDELIVERABLE = fail. responseMapping: { ACCEPTED: 'Continue', PENDING: 'Continue', @@ -64,7 +62,6 @@ function parseResponse({ httpStatus, ok, json }) { providerMessageId: (firstMessage && firstMessage.messageId) || null, providerStatusName: (status.groupName || status.name || '').toUpperCase() || null, providerStatusDescription: status.description || null, - providerResponse: json, }; } diff --git a/javascript/src/functions/providers/sinch.js b/javascript/src/functions/providers/sinch.js index 901b500..a3c7bd7 100644 --- a/javascript/src/functions/providers/sinch.js +++ b/javascript/src/functions/providers/sinch.js @@ -4,9 +4,8 @@ 'use strict'; -// Sinch provider: SMS via XMS Batches (POST /xms/v1/{plan}/batches, Bearer). -// Voice via the Calling TTS callout API (best-effort; Sinch Voice usually uses app-signed auth). -// Delivery status is async: XMS returns a batch/call id, not a final status. +// Sinch: SMS via XMS batches, voice via the Calling TTS callout. XMS returns a batch id, not a final +// delivery status — that arrives asynchronously by callback. const manifest = { id: 'sinch', @@ -30,7 +29,7 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { }; if (channel === 'voice') { - // Sinch Voice uses its own host (and normally app-signed auth, not the XMS token — verify). + // Sinch Voice uses its own host and normally app-signed auth, not the XMS token — verify. const voiceBase = env.SINCH_VOICE_ENDPOINT || 'https://calling.api.sinch.com'; const body = { method: 'ttsCallout', @@ -63,7 +62,6 @@ function parseResponse({ httpStatus, ok, json }) { providerMessageId: typeof messageOrCallId === 'string' ? messageOrCallId : (messageOrCallId && messageOrCallId.href) || null, providerStatusName: ok ? 'Dispatched' : (json && (json.text || json.status)) || null, providerStatusDescription: (json && (json.text || json.detailedStatus)) || null, - providerResponse: json, }; } diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index c82e0db..2a79465 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -4,9 +4,8 @@ 'use strict'; -// Soprano provider: Soprano Connect API (MEMS): POST {base}/messages/{sms|voice}; base is https:///cgpapi. -// Auth: X-MEMS-API-ID + X-MEMS-API-Key headers (both from Key Vault), or a Bearer JWT. -// Sender is a provisioned source endpoint (SOPRANO_SOURCE_ID), not a free-text string. Verified live (HTTP 201, ENROUTE). +// Soprano Connect (MEMS): POST {base}/messages/{sms|voice}, base https:///cgpapi. +// Auth: X-MEMS-API-ID + X-MEMS-API-Key, or a Bearer JWT. Verified live (HTTP 201, ENROUTE). const manifest = { id: 'soprano', @@ -47,17 +46,14 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { text: dispatch.message, clientReference: dispatch.correlationId || dispatch.messageId, }; - // Sender: a provisioned source endpoint (endpoints:[{type,id}]) is what Soprano accepts; free-text source is a fallback. - // Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A - // non-numeric account name is sent as a free-text source instead. + // Soprano wants a provisioned (numeric) source endpoint; a non-numeric name goes as free-text source. const account = env.EPP_PROVIDER_ACCOUNT_NAME; if (account && /^\d+$/.test(account)) { body.endpoints = [{ type: Number(env.SOPRANO_SOURCE_TYPE || 1), id: Number(account) }]; } else if (account) { body.source = account; } - // Voice: Soprano speaks the fully-rendered message via text-to-speech. `language` must be a full - // Nexmo voice code (e.g. en-US), not a bare `en`. + // `language` must be a full voice code (e.g. en-US), not a bare `en`. if (messageType === 'voice') { const voiceLanguage = env.SOPRANO_VOICE_LANGUAGE || (dispatch.locale && dispatch.locale.includes('-') ? dispatch.locale : 'en-US'); @@ -86,7 +82,6 @@ function parseResponse({ httpStatus, ok, json }) { providerMessageId: (payload.id != null ? String(payload.id) : null) || payload.messageId || null, providerStatusName: status, providerStatusDescription: payload.errorDescription || payload.statusText || payload.description || null, - providerResponse: json, }; } diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index c8ee5b1..67f557d 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -4,9 +4,8 @@ 'use strict'; -// Telesign provider: SMS via /v1/messaging, voice via /v1/voice (form-urlencoded). -// Auth: HTTP Basic (customer_id:api_key), or a Bearer token in oauth2 mode. -// Both customer_id and api_key come from Key Vault (rotate either by replacing the secret value — no redeploy). +// Telesign: SMS /v1/messaging, voice /v1/voice, form-urlencoded. +// Auth: HTTP Basic (customer_id:api_key) from Key Vault, or Bearer in oauth2 mode. const manifest = { id: 'telesign', @@ -82,7 +81,6 @@ function parseResponse({ httpStatus, ok, json }) { providerStatusCode: status.code != null ? String(status.code) : null, providerStatusName: null, providerStatusDescription: status.description || null, - providerResponse: json, }; } diff --git a/javascript/src/functions/security.js b/javascript/src/functions/security.js index 4db7247..9f2d6df 100644 --- a/javascript/src/functions/security.js +++ b/javascript/src/functions/security.js @@ -4,12 +4,11 @@ 'use strict'; -// Endpoint security: optional inbound bearer-token validation — anonymous unless REQUIRE_AUTH=true, -// which validates a Microsoft JWT (iss/aud/exp/RS256). 'jose' is lazy-loaded. +// Optional inbound bearer-token validation: anonymous unless EPP_REQUIRE_AUTH=true, which validates +// a Microsoft JWT (iss/aud/exp/RS256). Easy Auth is the primary gate; this is the backstop. const jwksByTenant = new Map(); -// Gets (and caches per issuer tenant) the remote JWKS used to verify inbound tokens. function getJwks(issuerTenantId) { if (!jwksByTenant.has(issuerTenantId)) { const { createRemoteJWKSet } = require('jose'); @@ -21,15 +20,12 @@ function getJwks(issuerTenantId) { return jwksByTenant.get(issuerTenantId); } -// Easy Auth's allowedApplications does this at the platform, but it is off in the standalone -// configuration, so without this any app in the tenant could reach the endpoint. -// azp is the v2 claim, appid the v1 one. +// azp is the v2 caller claim, appid the v1 one. function isExpectedCaller(payload, expectedClientId) { if (!expectedClientId) return true; return (payload.azp || payload.appid) === expectedClientId; } -// Validates the inbound bearer token when REQUIRE_AUTH is enabled (issuer, audience, expiry, RS256). async function validateToken(request, context, requestId) { if (String(process.env.EPP_REQUIRE_AUTH || 'false').toLowerCase() !== 'true') { return { ok: true, skipped: true }; @@ -49,8 +45,7 @@ async function validateToken(request, context, requestId) { try { const { jwtVerify } = require('jose'); - // Accept both the v2 (login.microsoftonline.com/.../v2.0) and v1 (sts.windows.net/.../) issuers, - // unless EPP_EXPECTED_ISSUER pins one explicitly. + // Accept both the v2 and v1 issuer forms unless EPP_EXPECTED_ISSUER pins one. const issuers = process.env.EPP_EXPECTED_ISSUER ? [process.env.EPP_EXPECTED_ISSUER] : [ diff --git a/javascript/test/auth.test.js b/javascript/test/auth.test.js index 6116541..b5e80c2 100644 --- a/javascript/test/auth.test.js +++ b/javascript/test/auth.test.js @@ -46,21 +46,16 @@ test('fails (generic reason) on an invalid token', async () => { }); // Easy Auth normally rejects the wrong caller at the platform; these cover the standalone path. -test('caller check: unpinned client id accepts any caller', () => { - assert.equal(isExpectedCaller({ azp: 'anything' }, ''), true); -}); - -test('caller check: rejects an unexpected caller', () => { - assert.equal(isExpectedCaller({ azp: 'some-other-app' }, 'expected-app'), false); -}); - -test('caller check: rejects a token carrying no caller claim', () => { - assert.equal(isExpectedCaller({}, 'expected-app'), false); -}); - -test('caller check: accepts the v2 azp claim', () => { - assert.equal(isExpectedCaller({ azp: 'expected-app' }, 'expected-app'), true); -}); +for (const [callerAppId, expected, allowed] of [ + ['anything', '', true], // unpinned client id accepts any caller + ['expected-app', 'expected-app', true], + ['some-other-app', 'expected-app', false], + [undefined, 'expected-app', false], // token carrying no caller claim +]) { + test(`caller check: appid=${callerAppId} expected=${expected || '(unpinned)'} -> ${allowed}`, () => { + assert.equal(isExpectedCaller({ azp: callerAppId }, expected), allowed); + }); +} test('caller check: accepts the v1 appid claim', () => { assert.equal(isExpectedCaller({ appid: 'expected-app' }, 'expected-app'), true); diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 05db40f..fff3223 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -22,7 +22,7 @@ const providerSecrets = { const { SecretClient } = require('@azure/keyvault-secrets'); mock.method(SecretClient.prototype, 'getSecret', async (name) => ({ value: providerSecrets[name] })); -const { dispatchOtp, getProvider } = require('../src/functions/dispatch'); +const { dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus } = require('../src/functions/dispatch'); let resp; let sent; @@ -55,7 +55,7 @@ for (const prov of ['infobip', 'telesign', 'sinch', 'soprano']) { for (const ch of ['sms', 'voice']) { test(`${prov}/${ch}: 200, message sent in body, https, provider auth scheme`, async () => { resp = { ok: true, status: 200, body: successBody[prov] }; - const r = await dispatchOtp(disp({ channel: ch }), { tenantId: 't', requestProvider: prov, context: ctx, requestId: 'r' }); + const r = await dispatchOtp(disp({ channel: ch }), { requestProvider: prov, context: ctx, requestId: 'r' }); assert.equal(r.httpStatus, 200); assert.match(sent.url, /^https:\/\//); assert.ok(sent.opts.body.includes('918273'), 'rendered message missing from body'); @@ -68,74 +68,75 @@ for (const prov of ['infobip', 'telesign', 'sinch', 'soprano']) { } } -test('Block outcome maps to 403', async () => { - resp = { ok: false, status: 200, body: { status: 'BLOCKED' } }; - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'soprano', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 403); -}); - -test('Fail outcome maps to the provider HTTP status (400)', async () => { - resp = { ok: false, status: 400, body: { messages: [{ status: { name: 'REJECTED' } }] } }; - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 400); - assert.equal(r.body.outcome, 'Fail'); -}); - -test('Fail with provider 429 maps to 429 (rate limit)', async () => { - resp = { ok: false, status: 429, body: { messages: [{ status: { name: 'REJECTED' } }] } }; - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 429); - assert.equal(r.body.outcome, 'Fail'); -}); - -test('Fail with provider 401/403 maps to 401 (auth)', async () => { - resp = { ok: false, status: 403, body: { messages: [{ status: { name: 'REJECTED' } }] } }; - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 401); - assert.equal(r.body.outcome, 'Fail'); -}); - -test('provider HTTP 200 with a REJECTED status still maps to Fail (not Continue)', async () => { - resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'REJECTED' } }] } }; - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.body.outcome, 'Fail'); - assert.equal(r.body.status, 'failed'); -}); - -test('provider HTTP 200 with a BLOCKED status maps to Block (403)', async () => { - resp = { ok: true, status: 200, body: { status: 'BLOCKED' } }; - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'soprano', context: ctx, requestId: 'r' }); - assert.equal(r.body.outcome, 'Block'); - assert.equal(r.httpStatus, 403); +// Outcome + HTTP mapping is pure, so it is asserted directly here instead of once per case through +// the whole dispatch pipeline (mirrors the .NET and Python contract tests). +test('outcome mapping and HTTP status', () => { + const infobip = getProvider('infobip').manifest; + const soprano = getProvider('soprano').manifest; + const telesign = getProvider('telesign').manifest; + + assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: 'DELIVERED' }), 'Continue'); + assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: 'REJECTED' }), 'Fail'); + assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: 'WATWAT' }), 'Fail'); + assert.equal(resolveOutcome(soprano, { success: true, providerStatusName: 'BLOCKED' }), 'Block'); + assert.equal(resolveOutcome(telesign, { success: true, providerStatusCode: '100' }), 'Continue'); + + assert.equal(outcomeToHttpStatus('Continue', 200), 200); + assert.equal(outcomeToHttpStatus('Block', 200), 403); + assert.equal(outcomeToHttpStatus('StepUp', 200), 409); + assert.equal(outcomeToHttpStatus('Fail', 429), 429); + assert.equal(outcomeToHttpStatus('Fail', 403), 401); + assert.equal(outcomeToHttpStatus('Fail', 422), 400); + assert.equal(outcomeToHttpStatus('Fail', 500), 502); }); test('endpoint timeout maps to 504', async () => { resp = 'TIMEOUT'; - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + const r = await dispatchOtp(disp(), { requestProvider: 'infobip', context: ctx, requestId: 'r' }); assert.equal(r.httpStatus, 504); assert.equal(r.body.outcome, 'Fail'); }); test('network error (non-timeout) maps to 502', async () => { resp = 'THROW'; - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + const r = await dispatchOtp(disp(), { requestProvider: 'infobip', context: ctx, requestId: 'r' }); assert.equal(r.httpStatus, 502); assert.equal(r.body.outcome, 'Fail'); }); test('unknown provider status fails closed even on HTTP 200 (§15)', async () => { resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'WATWATWAT' } }] } }; - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }); + const r = await dispatchOtp(disp(), { requestProvider: 'infobip', context: ctx, requestId: 'r' }); assert.equal(r.body.outcome, 'Fail'); assert.equal(r.body.status, 'failed'); }); +// The code and phone necessarily appear in the outbound provider request — that is the delivery. +test('the code and phone never reach the logs or the response body', async () => { + const logs = []; + resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'DELIVERED' }, messageId: 'x' }] } }; + const r = await dispatchOtp( + { destination: '+15551234567', message: 'Your code is 918273', channel: 'sms', messageId: 'm', correlationId: 'c' }, + { requestProvider: 'infobip', context: { log: (m) => logs.push(String(m)) }, requestId: 'r' }, + ); + + assert.equal(r.httpStatus, 200); + assert.ok(sent.opts.body.includes('918273'), 'the rendered message IS sent to the provider'); + for (const line of logs) { + assert.ok(!line.includes('918273'), `code leaked in a log line: ${line}`); + assert.ok(!line.includes('5551234567'), `phone leaked in a log line: ${line}`); + } + const body = JSON.stringify(r.body); + assert.ok(!body.includes('918273'), 'code leaked in response body'); + assert.ok(!body.includes('5551234567'), 'phone leaked in response body'); +}); + test('apiKey mode fails closed when the secret is missing (502)', async () => { const manifest = getProvider('soprano').manifest; const saved = JSON.parse(JSON.stringify(manifest.auth)); manifest.auth = { mode: 'apiKey', keyVaultSecretName: '__missing_secret__' }; try { - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'soprano', context: ctx, requestId: 'r' }); + const r = await dispatchOtp(disp(), { requestProvider: 'soprano', context: ctx, requestId: 'r' }); assert.equal(r.httpStatus, 502); assert.equal(r.body.reason, 'provider credential unavailable'); } finally { @@ -148,7 +149,7 @@ test('shutter returns 200 without sending', async () => { const orig = global.fetch; global.fetch = async (...a) => { calls++; return orig(...a); }; try { - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'infobip', shutter: true, context: ctx, requestId: 'r' }); + const r = await dispatchOtp(disp(), { requestProvider: 'infobip', shutter: true, context: ctx, requestId: 'r' }); assert.equal(r.httpStatus, 200); assert.equal(r.body.shutterProcessed, true); assert.equal(calls, 0); @@ -167,10 +168,10 @@ test('oauth2 mode uses a Bearer token and fails closed without one', async () => const saved = JSON.parse(JSON.stringify(manifest.auth)); manifest.auth.mode = 'oauth2'; try { - await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'sinch', context: ctx, requestId: 'r', acquireProviderToken: async () => 'TKN' }); + await dispatchOtp(disp(), { requestProvider: 'sinch', context: ctx, requestId: 'r', acquireProviderToken: async () => 'TKN' }); assert.equal(sent.opts.headers.Authorization, 'Bearer TKN'); - const noToken = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'sinch', context: ctx, requestId: 'r' }); + const noToken = await dispatchOtp(disp(), { requestProvider: 'sinch', context: ctx, requestId: 'r' }); assert.equal(noToken.httpStatus, 502); // credential unavailable → 502, not 401 } finally { manifest.auth = saved; @@ -182,7 +183,7 @@ test('apiKey provider that needs an identity fails closed when the identity secr const saved = JSON.parse(JSON.stringify(manifest.auth)); manifest.auth.identityKeyVaultSecretName = '__missing_identity__'; try { - const r = await dispatchOtp(disp(), { tenantId: 't', requestProvider: 'telesign', context: ctx, requestId: 'r' }); + const r = await dispatchOtp(disp(), { requestProvider: 'telesign', context: ctx, requestId: 'r' }); assert.equal(r.httpStatus, 502); assert.equal(r.body.reason, 'provider credential unavailable'); } finally { diff --git a/javascript/test/security.test.js b/javascript/test/security.test.js deleted file mode 100644 index a69aa66..0000000 --- a/javascript/test/security.test.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; - -// Privacy assertion: the OTP code and phone number must never appear in logs or the response body. -// (They necessarily appear in the outbound provider request — that is the delivery itself.) - -const { test, mock } = require('node:test'); -const assert = require('node:assert'); - -// The provider secret comes from Key Vault via managed identity in production; mock getSecret here. -process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; -process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; -const { SecretClient } = require('@azure/keyvault-secrets'); -mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'ib' })); - -const { dispatchOtp } = require('../src/functions/dispatch'); - -const CODE = '918273'; -const PHONE = '+15551234567'; - -global.fetch = async () => ({ - ok: true, - status: 200, - text: async () => JSON.stringify({ messages: [{ status: { name: 'DELIVERED' }, messageId: 'x' }] }), -}); - -test('OTP code and phone never appear in logs or the response body', async () => { - const logs = []; - const ctx = { log: (m) => logs.push(String(m)) }; - - const r = await dispatchOtp( - { destination: PHONE, message: `Your code is ${CODE}`, channel: 'sms', messageId: 'm', correlationId: 'c' }, - { tenantId: 't', requestProvider: 'infobip', context: ctx, requestId: 'r' }, - ); - assert.equal(r.httpStatus, 200); - - for (const line of logs) { - assert.ok(!line.includes(CODE), `code leaked in a log line: ${line}`); - assert.ok(!line.includes(PHONE), `phone leaked in a log line: ${line}`); - } - - const bodyStr = JSON.stringify(r.body); - assert.ok(!bodyStr.includes(CODE), 'code leaked in response body'); - assert.ok(!bodyStr.includes(PHONE), 'phone leaked in response body'); -}); diff --git a/python/function_app.py b/python/function_app.py index 6eae54d..4bc21af 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -1,9 +1,6 @@ -"""External Phone Provider OTP Function — Python v2 model. HTTP trigger: POST /api/SendOtp — the SAS → -External Phone Provider delivery endpoint. Validates the caller, parses the cleartext routing envelope, -decrypts the JWE delivery context (PII lives there), dispatches, and echoes the nonce to prove decryption. - -Every line starts with [EPP], so a whole delivery can be pulled out of a noisy log with one filter: - Application Insights : traces | where message startswith "[EPP]" | order by timestamp asc +"""POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the caller, parses +the cleartext routing envelope, decrypts the JWE delivery context, dispatches to the provider, and +echoes the nonce to prove decryption. Every line is tagged [EPP] so one filter pulls a whole delivery. """ import base64 import json @@ -28,7 +25,6 @@ from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider from src.providers.telesign import TelesignProvider -from src.registry import ProviderRegistry from src.secrets import SecretResolver from src.security import validate_token @@ -51,7 +47,7 @@ def _log(label, value): def _read_caller_app_id(req): - """Easy Auth has already validated the token; this only records which identity actually arrived.""" + """Easy Auth has already validated the token; this records which identity arrived.""" encoded = req.headers.get("x-ms-client-principal") if not encoded: return None @@ -113,8 +109,7 @@ def send_otp(req: func.HttpRequest) -> func.HttpResponse: correlation_id = envelope["correlation_id"] or header_correlation_id or request_id - # Surfaced rather than swallowed: the passcode expires before it can be used, so delivering it - # would only produce a failed sign-in and a support call. + # Surfaced rather than swallowed: the passcode expires before it can be used. ttl_seconds = envelope["ttl_seconds"] if isinstance(ttl_seconds, (int, float)) and not isinstance(ttl_seconds, bool) and ttl_seconds <= 0: logging.warning("%s ttlSeconds is %s; the passcode has expired.", TAG, ttl_seconds) diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 7fd031f..aca13cb 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -1,5 +1,5 @@ -"""The delivery pipeline, in request order: parse the cleartext SAS envelope, decrypt the JWE delivery -context that carries the PII, then dispatch to the bound provider. Fail-closed.""" +"""Delivery pipeline: parse the cleartext SAS envelope, decrypt the JWE that carries the PII, then +dispatch to the configured provider. Fail-closed — only a Continue outcome is "accepted".""" import base64 import json import os @@ -41,7 +41,6 @@ def resolve_outcome(manifest, parsed): def to_http_status(outcome, provider_http_status): - """A Fail surfaces the provider's failure class.""" if outcome == CONTINUE: return 200 if outcome == BLOCK: @@ -59,7 +58,7 @@ def to_http_status(outcome, provider_http_status): class ProviderRegistry: - """One provider is active per deployment — selection is config, not routing.""" + """One provider is active per deployment; request_provider is a test override.""" def __init__(self, adapters): self._by_id = {adapter.manifest["id"].lower(): adapter for adapter in adapters} @@ -73,7 +72,7 @@ def resolve(self, request_provider): return self.get(request_provider or os.environ.get("EPP_PROVIDER_NAME")) -# Channel: 1=Sms, 2=Voice (0=Undefined). DeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). +# Channel: 1=Sms, 2=Voice. DeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). CHANNEL_BY_CODE = {1: "sms", 2: "voice"} CHANNEL_BY_NAME = {"sms": 1, "voice": 2} MODE_LIVE = 1 @@ -84,7 +83,6 @@ def resolve(self, request_provider): def _normalize_channel(channel): - """Accepts the int enum (1/2) or the string form ('sms'/'voice').""" if isinstance(channel, bool): return None if channel in CHANNEL_BY_CODE: @@ -95,7 +93,6 @@ def _normalize_channel(channel): def _normalize_mode(mode): - """Accepts the int enum (1/2) or the string form ('live'/'evaluation').""" if isinstance(mode, bool): return None if mode in (MODE_LIVE, MODE_EVALUATION): @@ -130,7 +127,6 @@ def parse_envelope(payload): def read_protected_header(compact_jwe): - """Reads the first segment without decrypting, so kid/alg/enc can be logged.""" header_segment = compact_jwe.split(".")[0] header_segment += "=" * (-len(header_segment) % 4) return json.loads(base64.urlsafe_b64decode(header_segment)) @@ -144,7 +140,7 @@ def key_provider(_kid): def _assert_well_formed_jwe(compact_jwe): - # Reject oversized or malformed input before base64-decoding or allocating buffers. + # Reject oversized or malformed input before decoding or allocating buffers. if not isinstance(compact_jwe, str) or not compact_jwe: raise ValueError("malformed JWE") if len(compact_jwe) > MAX_JWE_LENGTH: @@ -154,14 +150,13 @@ def _assert_well_formed_jwe(compact_jwe): raise ValueError("malformed JWE: expected five non-empty segments") -# Imported once and reused: a per-delivery RSA import would land inside the response budget, and would -# turn a bad key into a failure on every call instead of one obvious first failure. +# Imported once: a per-delivery RSA import would sit inside the response budget. _key_cache = {} def _normalize_pem(value): """The setup script stores the key as base64 over the PEM so its newlines survive being carried as - a secret and then as an app setting, so accept either form.""" + an app setting, so accept either form.""" text = value if isinstance(value, str) else value.decode("utf-8") if "-----BEGIN" in text: return text @@ -191,8 +186,7 @@ def decrypt_delivery_context(compact_jwe, key_provider): def _space_passcode_for_voice(message): - """A TTS engine reads 641895 as "six hundred forty-one thousand eight hundred ninety-five", which - no user can type. Spacing the digits makes it read them one at a time.""" + """Left alone, TTS reads 641895 as "six hundred forty-one thousand...", which no user can type.""" return re.sub(r"\b\d{4,8}\b", lambda m: " ".join(m.group(0)), message or "", count=1) diff --git a/python/src/security.py b/python/src/security.py index bcc6d16..0e69006 100644 --- a/python/src/security.py +++ b/python/src/security.py @@ -1,11 +1,10 @@ """Validates the Entra JWT when EPP_REQUIRE_AUTH=true (aud/issuer/JWKS, RS256). -No-op pass-through otherwise.""" +No-op pass-through otherwise — Easy Auth is the primary gate; this is the backstop.""" import os import jwt from jwt import PyJWKClient -# Cache one JWKS client per issuer tenant so signing keys are fetched once, not per request. _jwks_clients = {} @@ -51,8 +50,7 @@ def validate_token(authorization_header): if claims.get("iss") not in allowed_issuers: return False, "token validation failed", None - # Easy Auth's allowedApplications does this at the platform, but it is off in the standalone - # configuration, so without this any app in the tenant could reach the endpoint. + # azp is the v2 caller claim, appid the v1 one. expected_client_id = os.environ.get("EPP_EXPECTED_CLIENT_ID") if expected_client_id and (claims.get("azp") or claims.get("appid")) != expected_client_id: return False, "unexpected caller", None diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py new file mode 100644 index 0000000..87d6526 --- /dev/null +++ b/python/tests/test_function_app.py @@ -0,0 +1,124 @@ +"""Trigger-level tests for the SendOtp HTTP handler. + +The import + route assertions are the regression guard for module-level breakage: a bad `from src...` +line makes the whole Function App fail to start, and the engine-level tests never import this module, +so they stay green while nothing can run. +""" +import json +import os + +import azure.functions as func +import pytest +from jwcrypto import jwe, jwk + +import src.dispatch as dispatch_module + +# Set before importing function_app: it builds its engine and key provider at module load. +_KEY = jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") +os.environ["EPP_DECRYPTION_KEY_PEM"] = _KEY.export_to_pem(private_key=True, password=None).decode("utf-8") +os.environ["EPP_PROVIDER_NAME"] = "infobip" +os.environ["EPP_PROVIDER_ENDPOINT"] = "https://api.infobip.com" + +import function_app # noqa: E402 + +# Resolved once: app.get_functions() rebuilds bindings and rejects a second call. +_FUNCTIONS = function_app.app.get_functions() +_HANDLER = _FUNCTIONS[0].get_user_function() + + +class _FakeSecrets: + def resolve(self, name): + return "ib" + + +class _FakeResponse: + def __init__(self, status_code, body): + self.status_code = status_code + self._body = body + + def json(self): + return self._body + + +class _InlineThread: + """Runs the background delivery inline so assertions don't race the worker thread.""" + + def __init__(self, target=None, name=None, daemon=None): + self._target = target + + def start(self): + self._target() + + +@pytest.fixture(autouse=True) +def _wire(monkeypatch): + monkeypatch.setattr(function_app._engine, "secrets", _FakeSecrets()) + monkeypatch.setattr(function_app.threading, "Thread", _InlineThread) + + +def _request(body): + raw = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8") + return func.HttpRequest(method="POST", url="/api/SendOtp", headers={}, params={}, body=raw) + + +def _envelope(**overrides): + context = { + "nonce": "nonce-abc", + "phoneNumber": "+14255551234", + "locale": "en-US", + "message": "Your code is 123456", + } + protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": "test-key"} + token = jwe.JWE(json.dumps(context).encode("utf-8"), protected=json.dumps(protected)) + token.add_recipient(_KEY) + + envelope = { + "type": "microsoft.mfa.otpDeliver.v1", + "tenantId": "tenant-1", + "correlationId": "corr-1", + "channel": 1, + "mode": 1, + "ttlSeconds": 60, + "encryptedDeliveryContext": token.serialize(compact=True), + } + envelope.update(overrides) + return envelope + + +def test_app_imports_and_registers_the_route(): + assert [f.get_function_name() for f in _FUNCTIONS] == ["send_otp"] + + +def test_invalid_json_is_400(): + response = _HANDLER(_request(b"{ not json")) + assert response.status_code == 400 + + +def test_live_envelope_echoes_the_nonce(monkeypatch): + sent = {} + + def fake_request(method, url, headers=None, data=None, timeout=None): + sent["url"] = url + return _FakeResponse(200, {"messages": [{"status": {"groupName": "PENDING"}, "messageId": "x"}]}) + + monkeypatch.setattr(dispatch_module.requests, "request", fake_request) + + response = _HANDLER(_request(_envelope())) + body = json.loads(response.get_body()) + + assert response.status_code == 200 + assert body["nonce"] == "nonce-abc" + assert body["correlationId"] == "corr-1" + assert sent["url"].startswith("https://") + + +def test_evaluation_mode_does_not_send(monkeypatch): + def fail(*args, **kwargs): + raise AssertionError("evaluation mode must not send") + + monkeypatch.setattr(dispatch_module.requests, "request", fail) + + response = _HANDLER(_request(_envelope(mode=2))) + + assert response.status_code == 200 + assert json.loads(response.get_body())["nonce"] == "nonce-abc"