From 2c3e88385e7d1074382c443dd05dbb70d0c7ad44 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Mon, 17 Aug 2026 11:38:20 -0700 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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` |