Add External Phone Provider OTP-delivery Azure Function sample (JavaScript, .NET, Python) - #1
Merged
Merged
Conversation
added 2 commits
August 17, 2026 11:38
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.
Drop scripts/ (JWE key provisioning + nonce-echo test client) and the scripts-specific .gitignore entry.
There was a problem hiding this comment.
Pull request overview
Adds a provider-agnostic CYOT OTP-delivery Azure Function sample implemented in JavaScript (Node.js), C# (.NET 8 isolated), and Python (Functions v2 model), all conforming to a shared contract and shipped with conformance tests and CI.
Changes:
- Introduces a shared contract + onboarding docs and wires CI to run conformance tests in all three languages.
- Implements dispatch engines + provider adapters (Infobip, Telesign, Soprano, Sinch) with Key Vault (managed identity) secret resolution and fail-closed outcome mapping.
- Adds envelope parsing + JWE decryption + optional Entra JWT validation, plus language-specific HTTP triggers for
POST /api/SendOtp.
Reviewed changes
Copilot reviewed 63 out of 67 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Repo entrypoint: links to implementations + contract/onboarding docs. |
| python/tests/test_engine.py | Python engine conformance tests (dispatch pipeline + fail-closed mapping). |
| python/tests/test_cyot.py | Python envelope + JWE round-trip + mapping tests. |
| python/tests/test_contract.py | Python pure-contract tests (outcome/status mapping, provider request shaping). |
| python/src/security.py | Python Entra JWT validation (REQUIRE_AUTH gating). |
| python/src/secrets.py | Python Key Vault secret resolver w/ managed identity + short TTL cache. |
| python/src/registry.py | Python provider adapter registry + DEFAULT_PROVIDER resolution. |
| python/src/providers/telesign.py | Python Telesign adapter (build/parse + mapping). |
| python/src/providers/soprano.py | Python Soprano adapter (build/parse + mapping). |
| python/src/providers/sinch.py | Python Sinch adapter (build/parse + mapping). |
| python/src/providers/infobip.py | Python Infobip adapter (build/parse + mapping). |
| python/src/providers/init.py | Python providers package marker. |
| python/src/outcome.py | Python provider-status → outcome → HTTP status mapping (fail-closed). |
| python/src/models.py | Python shared contract models/constants (DispatchRequest + outcomes). |
| python/src/dispatch.py | Python dispatch engine: resolve → creds → endpoint → send → map outcome/status. |
| python/src/cyot.py | Python envelope parsing + JWE validation/decrypt + mapping to dispatch shape. |
| python/src/init.py | Python src package marker. |
| python/requirements.txt | Python dependency ranges for Functions runtime + crypto/JWT + Azure SDKs. |
| python/README.md | Python implementation documentation + build/test/run/deploy instructions. |
| python/host.json | Python Functions host config (AI sampling + extension bundle). |
| python/function_app.py | Python HTTP trigger for SendOtp (auth/envelope/JWE/dispatch + nonce echo). |
| javascript/test/sendotp.test.js | JS SendOtp handler tests: validation, decrypt, evaluation, auth rejection. |
| javascript/test/security.test.js | JS privacy test: ensure OTP + phone never in logs/response body. |
| javascript/test/dispatch.test.js | JS dispatch integration tests: providers, mapping, shutter, fail-closed. |
| javascript/test/auth.test.js | JS token validation unit tests (REQUIRE_AUTH behavior). |
| javascript/src/functions/SendOtp.js | JS SendOtp HTTP handler (auth/envelope/JWE/dispatch + nonce echo). |
| javascript/src/functions/security.js | JS Entra JWT validation (JWKS + issuer/audience). |
| javascript/src/functions/providers/telesign.js | JS Telesign adapter (manifest/build/parse). |
| javascript/src/functions/providers/soprano.js | JS Soprano adapter (manifest/build/parse). |
| javascript/src/functions/providers/sinch.js | JS Sinch adapter (manifest/build/parse). |
| javascript/src/functions/providers/infobip.js | JS Infobip adapter (manifest/build/parse). |
| javascript/src/functions/dispatch.js | JS dispatch engine: provider registry, Key Vault secrets, send + mapping. |
| javascript/src/functions/cyot.js | JS envelope parsing + JWE validation/decrypt + mapping to dispatch shape. |
| javascript/README.md | JS implementation documentation + configuration catalog and examples. |
| javascript/package.json | JS dependencies + test/start scripts. |
| javascript/package-lock.json | JS dependency lockfile for reproducible installs in CI. |
| javascript/host.json | JS Functions host config (AI sampling + extension bundle). |
| dotnet/tests/EnvelopeTests.cs | .NET envelope parsing + JWE round-trip tests. |
| dotnet/tests/EngineTests.cs | .NET engine conformance tests (dispatch pipeline + privacy). |
| dotnet/tests/Cyot.Otp.Tests.csproj | .NET test project config + references (compiles Src directly). |
| dotnet/tests/ContractTests.cs | .NET pure-contract tests (mapping + request shaping + registry). |
| dotnet/Src/TokenValidator.cs | .NET Entra JWT validation w/ cached OIDC config/JWKS. |
| dotnet/Src/SecretResolver.cs | .NET Key Vault secret resolver w/ managed identity + TTL cache. |
| dotnet/Src/Providers/TelesignProvider.cs | .NET Telesign adapter (build/parse + mapping). |
| dotnet/Src/Providers/SopranoProvider.cs | .NET Soprano adapter (build/parse + mapping). |
| dotnet/Src/Providers/SinchProvider.cs | .NET Sinch adapter (build/parse + mapping). |
| dotnet/Src/Providers/InfobipProvider.cs | .NET Infobip adapter (build/parse + mapping). |
| dotnet/Src/ProviderRegistry.cs | .NET provider registry + DEFAULT_PROVIDER resolution. |
| dotnet/Src/OutcomeMapper.cs | .NET provider-status → outcome → HTTP status mapping (fail-closed). |
| dotnet/Src/Models.cs | .NET shared contract records/interfaces (DispatchRequest, manifests, etc.). |
| dotnet/Src/ISecretResolver.cs | .NET abstraction seam for secrets (testability). |
| dotnet/Src/IProviderAdapter.cs | .NET adapter interface (manifest + build/parse). |
| dotnet/Src/DispatchEngine.cs | .NET dispatch engine: resolve → creds → endpoint → send → map outcome/status. |
| dotnet/Src/DeliveryContext.cs | .NET JWE decryptor + key-provider contract + Key Vault key provider. |
| dotnet/Src/CyotEnvelope.cs | .NET envelope record + parser/validation. |
| dotnet/README.md | .NET implementation documentation + build/test/run/deploy instructions. |
| dotnet/Properties/launchSettings.json | .NET local launch profile. |
| dotnet/Program.cs | .NET Functions host startup + DI registration for engine/providers. |
| dotnet/host.json | .NET Functions host config (OpenTelemetry telemetryMode). |
| dotnet/Functions/SendOtp.cs | .NET SendOtp HTTP trigger (auth/envelope/JWE/dispatch + nonce echo). |
| dotnet/dotnet.csproj | .NET Functions project file + package refs. |
| dotnet/.gitignore | .NET-specific ignore rules (local.settings.json, VS artifacts, etc.). |
| docs/ONBOARDING.md | High-level onboarding guide (shared across languages). |
| docs/local.settings.sample.json | Reference app settings template for local/azure configuration. |
| docs/CONTRACT.md | Shared language-agnostic contract + required behaviors/test scenarios. |
| .gitignore | Repo-wide ignores (local.settings.json, keys, build artifacts). |
| .github/workflows/ci.yml | CI workflow: runs JS/node:test, .NET xUnit, Python pytest. |
Files not reviewed (1)
- javascript/package-lock.json: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
added 2 commits
August 18, 2026 14:44
- 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
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.
added 13 commits
August 21, 2026 09:36
…ntract
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.
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.
…the docs No implementation has read this setting since the retry path was dropped, so the samples were advertising a knob that does nothing.
The message logged on the preceding line already contains the passcode, so extracting it again only wrote a second copy of the secret to the log. Removes the helper from all three languages and the now-unused re import in Python.
…y Auth EPP_EXPECTED_CLIENT_ID was only ever compared against the Easy Auth x-ms-client-principal header, and the comparison was skipped when that header was absent. None of the three token validators inspected azp or appid, so with Easy Auth disabled and EPP_REQUIRE_AUTH=true any application in the tenant holding a token for the audience was accepted. All three validators now reject a token whose azp/appid does not match EPP_EXPECTED_CLIENT_ID. Adds five tests over the extracted isExpectedCaller decision.
config.js claimed to hold every setting the endpoint reads, but dispatch.js and security.js read their own directly. Its provider.accountName and provider.timeoutMs were read nowhere, and its DEFAULT_TIMEOUT_MS was a second competing definition of the 1500 ms default that dispatch.js already owns. Comment now describes what the module actually is. Python discarded the dispatch body; it now logs outcome, providerStatus and providerMessageId on the [EPP] line as JavaScript does.
The envelope parsing and JWE decryption now sit at the top of dispatch.js in request order, ahead of the provider dispatch code. No test imported cyot.js directly, so the fold is confined to the module boundary.
TokenValidator, ProviderRegistry and SecretResolver called Environment.GetEnvironmentVariable directly while every other class took the injected IEnv, so they could not be exercised with a fake environment. That is why the caller check added earlier had no .NET coverage. Extracts TokenValidator.IsExpectedCaller as a pure method, mirroring the JavaScript isExpectedCaller, and adds six tests over it and the require-auth short circuit.
… .NET, and rename Cyot to Epp Matches the JavaScript layout: one delivery module per sample holding envelope parsing, JWE decryption and provider dispatch in request order. Removes python/src/cyot.py, dotnet/Src/CyotEnvelope.cs and dotnet/Src/DeliveryContext.cs. Namespace Cyot.Otp becomes Epp.Otp, CyotEnvelope becomes Envelope, CyotDeliveryContext becomes DeliveryContext, and the test project is renamed. Wire-level identifiers SendCyotOtpRequest, CyotChannel, CyotDeliveryMode and CyotEndpointResponse are left alone, since those are Microsoft's and appear on the wire.
Removes eight comments that described what the following line already says, keeping the ones that carry information the code cannot: the Key Vault role requirement, the fail-closed outcome rule, and the provider failure-class mapping.
… unused JavaScript exports models.py (18 lines), outcome.py (30) and registry.py (16) each had a single consumer and now live in dispatch.py, matching the JavaScript layout. Python src/ goes from 7 files to 4. dispatch.js exported readProtectedHeader, loadPrivateKey, CHANNEL_BY_CODE, resolveSecretValue and DEFAULTS; none were imported anywhere, so the module's public surface shrinks from 11 symbols to 6.
It was byte-identical to test_envelope.py, so the eight envelope tests were running twice.
…mend Easy Auth on function_app.py imported ProviderRegistry a second time from src.registry, a module that does not exist, so the Python Function App raised ModuleNotFoundError at load and could not start. Nothing caught it: no test imported the trigger module. Add python/tests/test_function_app.py covering the trigger itself (route registration, invalid JSON, nonce echo, Evaluation mode). Verified it fails on the original bug by reintroducing the bad import. Also: - Remove the Key Vault warm-up (JS only; .NET and Python never had it) and take the cold start for now. - Drop the channel/text arguments passed to parseResponse that no adapter reads; JS now matches CONTRACT.md and the .NET/Python signatures. - Stop passing tenantId/requestProvider into dispatchOtp from SendOtp: both re-read EPP_PROVIDER_NAME, and .NET/Python already pass null there. - Export resolveOutcome/outcomeToHttpStatus and assert the mapping directly instead of once per case through the whole pipeline, mirroring the .NET and Python contract tests. JS 42 -> 37 tests. - Reduce comments to one-line rationale across all three languages. - Document Easy Auth ON as the primary gate (the trigger is authLevel: anonymous) with EPP_REQUIRE_AUTH=true as the backstop, since Easy Auth is configured outside the code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Contributes the provider-agnostic External Phone Provider OTP-delivery Azure Function sample in three languages (Node v4, .NET 8 isolated, Python v2) against one shared contract (docs/CONTRACT.md). One dispatch engine + drop-in provider adapters (Infobip, Telesign, Soprano, Sinch); Entra JWT validation (REQUIRE_AUTH); JWE delivery context pinned to RSA-OAEP-256 + A256GCM with 5-segment/size validation; provider secrets in Key Vault via managed identity; 202 Accepted with verbatim nonce echo; fail-closed outcome-to-HTTP mapping; xUnit/node:test/pytest conformance suites. Verified end-to-end against a live Azure deployment across all three languages and all provisioned providers.
Note: 'CYOT' (Choose Your Own Telecom) remains the internal code name and is retained only in wire-level identifiers (type names, the CYOT_JWE_PRIVATE_KEY_PEM app setting, and the SAS User-Agent); customer-facing docs use 'External Phone Provider'.
See docs/ONBOARDING.md to get started.