Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,60 @@ Request `tenantId`, `channel`, `mode` and `ttlSeconds` are request data, not ext
The trusted tenant issuer, endpoint-app audience and authorized SAS caller are configured in Easy Auth,
not in application environment settings or incoming request data.

## Configure environment variables

Use the [sample settings](docs/local.settings.sample.json) as the starting point for the chosen
runtime. All entries in its `Values` object are **strings**. The application reads environment
variables; Azure Functions Core Tools loads that `Values` object for local runs.

The sample uses `node`; change it to `python` or `dotnet-isolated` for those runtimes. Replace the
provider, endpoint, vault and test-key placeholders before use. Its storage value assumes **Azurite
is running**; do not copy `UseDevelopmentStorage=true` into Azure. Optional settings stay in the table
below rather than appearing as required placeholders in the sample. Keep explanatory comments outside
`Values`, otherwise the host loads them as environment variables too.

The local settings file is an environment-variable input for the Functions host, **not a serialized
`AppConfig` or request model**. For example, `EPP_PROVIDER_NAME` becomes `config.providerName` in
JavaScript, `config.provider_name` in Python, and `config.ProviderName` in .NET. The refactor changed
how code accesses configuration, not the environment-variable names.

| Variable | When needed | Value |
|---|---|---|
| `AzureWebJobsStorage` | Functions host storage | Local sample: `UseDevelopmentStorage=true` with Azurite running. Configure Azure host storage separately for the selected plan. |
| `FUNCTIONS_WORKER_RUNTIME` | Functions host | `node`, `python`, or `dotnet-isolated`—exactly one value matching the chosen implementation. |
| `EPP_DECRYPTION_KEY_PEM` | Every request | Local test PEM or base64 PEM. In Azure, use a Key Vault reference resolving to the private-key secret. |
| `EPP_ENCRYPTION_KEY_ID` | Optional | Expected encryption key ID; mismatch only produces an advisory warning. |
| `EPP_PROVIDER_NAME` | Live delivery | Selected adapter's manifest ID. No default provider. |
| `EPP_PROVIDER_ENDPOINT` | Live delivery | HTTPS **base URL**, in the same environment as the provider credentials; the adapter adds its route. |
| `EPP_PROVIDER_TIMEOUT_MS` | Optional | Decimal milliseconds. Defaults to `1500`, capped at `2500`; not an end-to-end deadline. |
| `EPP_PROVIDER_ACCOUNT_NAME` | Adapter-dependent | Sender/account metadata, not an API key or credential identity. |
| `KEY_VAULT_URL` | Provider credential lookup | URI of the vault containing the manifest-named provider secrets. Separate from the encryption-key reference. |
| `AZURE_CLIENT_ID` | Optional | User-assigned managed identity's client ID for Key Vault. Leave unset for system-assigned identity. |

1. **Locally:** create private local settings beside the chosen runtime's host file, following its
[JavaScript](javascript/README.md#environment-configuration), [Python](python/README.md#environment-configuration)
or [.NET](dotnet/README.md#environment-configuration) instructions. Restart the host after edits.
2. **In Azure:** set the same application variables on the selected Function App (or serving slot)
under **Settings → Environment variables → App settings**, then apply the changes. Local settings
are not published automatically. Configure host storage separately for the selected hosting plan.
3. Store provider API keys and any required identity secrets in Key Vault using the **exact names in
the adapter manifest**. Grant that app/slot's managed identity *Key Vault Secrets User* on those
secrets. An API key in a local environment variable is not a supported replacement for the resolver.

Evaluation requests do not need provider variables or provider secrets. They still need the decryption
key. The default credential resolvers use `ManagedIdentityCredential`, **not** the developer's CLI
login; ordinary local machines have no managed-identity endpoint. Use offline tests or loopback-only
evaluation locally, or an explicitly injected test resolver for integration work. Never commit local
settings, keys or test credentials.

Core Tools does not resolve Azure Key Vault reference expressions locally. Supply the local test PEM
or base64 PEM directly; use a reference such as `@Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/<private-key-secret>/)`
for `EPP_DECRYPTION_KEY_PEM` in Azure app settings, where the platform resolves it.

Configure inbound issuer/audience/caller trust in **Easy Auth**, not these application variables.
Incoming `tenantId`, `channel`, `mode` and `ttlSeconds` are request data. No outbound OAuth settings
are supported by this main-based implementation.

## Security

**Easy Auth (App Service Authentication) is the only caller-authentication gate, before the anonymous
Expand Down Expand Up @@ -69,3 +123,11 @@ authentication; [separate deployed security checks](docs/ONBOARDING.md#4-package
`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).

### Future pull requests

Start a short-lived branch from up-to-date `main`. After review and passing checks, select **Squash
and merge** to place one commit on `main`, then delete that PR's feature branch. Squashing is a merge
choice, not automatic just because commits are on a feature branch. Do not merge old feature histories
into a new branch or delete other branches containing unmerged work. This workflow does not rewrite
existing `main` history.
44 changes: 34 additions & 10 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
This defines the shared contract for [JavaScript](../javascript/), [Python](../python/) and
[.NET](../dotnet/). See [production limitations](#production-limitations) before production use.

> **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`) and the caller's `User-Agent`. App settings use the `EPP_` prefix.
> **Naming.** EPP means **External Phone Provider**. App settings use the `EPP_` prefix; the
> request and delivery models are `Envelope`, `DeliveryContext` and `DispatchRequest`.
> Documentation names do not change the external JSON fields or `microsoft.mfa.otpDeliver.v1` version.

The design is **one dispatch engine + registered provider adapters**, with one selected provider per
deployment. API-specific paths, headers, payloads and status rules belong in adapters, not this guide.
Expand All @@ -26,15 +26,15 @@ the cleartext envelope carries routing/scheduling only.
| Header | Notes |
|--------|-------|
| `Authorization` | consumed by platform authentication, not parsed or echoed by handler |
| `User-Agent` | e.g. `Microsoft-AzureMFA-SAS-CYOT/1.0`; not logged |
| `User-Agent` | caller-supplied identifier; not interpreted or logged |
| `x-ms-correlation-id` | tracing only; fallback for envelope `correlationId`, not authentication |
| `x-ms-client-request-id` | per-attempt tracing id (used as `messageId`), not authentication |

Forwarded headers, including `x-ms-client-principal`, do not establish trust by themselves and cannot
replace the required Easy Auth gate. The handler does not use them to authenticate callers or forward
the incoming `Authorization` header to the provider. Configure Easy Auth as described in section 5.

### Request body — `SendCyotOtpRequest` (cleartext envelope)
### EPP request body — `Envelope` (cleartext envelope)

| Field | Required | Notes |
|-------|----------|-------|
Expand All @@ -46,6 +46,10 @@ the incoming `Authorization` header to the provider. Configure Easy Auth as desc
| `ttlSeconds` | no | positive JSON integer, at most `2147483647`; null, booleans, strings, fractions and nonpositive values are rejected. Use canonical integer notation (`60`, not `60.0` or `6e1`) across runtimes |
| `encryptedDeliveryContext` | yes | JWE compact serialization (see below) |

Canonical integer notation is a caller requirement, not a portable raw-JSON-token check: JavaScript's
JSON parser normalizes `60.0` and `6e1` to `60`, while Python/.NET reject those representations here.
Always send `60` to obtain the same result across runtimes; no custom JSON tokenizer is used.

Unknown `type`, invalid `ttlSeconds`, unsupported `channel` or `mode`, or missing/empty `encryptedDeliveryContext` → `400`. Arrays, objects and booleans are not channel/mode values.

These are request data, not settings to provision. The TTL check validates the supplied value; it
Expand All @@ -58,7 +62,7 @@ Alg: **RSA-OAEP-256** (CEK wrap) + **A256GCM** (content). The JOSE protected hea
this sample uses the single configured RSA private key (`EPP_DECRYPTION_KEY_PEM`, a Key Vault
reference in Azure), not a multi-key lookup. The compact JWE must have **exactly five non-empty
segments** and at most **16,384 characters**; `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`:
verified before any plaintext is used. Decrypted plaintext = `DeliveryContext`:

The original compact JWE is passed unchanged to the JOSE library. Parsing header fields for the
advisory key-ID check must not replace the original protected-header bytes used for authentication.
Expand All @@ -83,7 +87,7 @@ JWE provides payload confidentiality and integrity, **not SAS caller authenticat
public key can encrypt a request. The nonce acknowledges decryption; it is not an authentication
credential or replay protection, and a fixed nonce cannot substitute for Easy Auth.

### Response — `CyotEndpointResponse` (JSON)
### EPP response (JSON)

```json
{ "nonce": "<echo of request nonce>", "correlationId": "<echo>", "providerStatus": "accepted" }
Expand Down Expand Up @@ -151,8 +155,22 @@ Each provider is one unit exposing three things:
- `auth` — `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }`; other modes fail closed
- `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 }`
- **`parseResponse({ httpStatus, ok, json })`** → `ParsedResponse`, containing `success`,
`providerHttpStatus`, optional `providerMessageId`, `providerStatusName`, `providerStatusCode`
and `providerStatusDescription` (snake_case attributes in Python, PascalCase in .NET).

The adapter reads its API-specific JSON and constructs a normalized `ParsedResponse` object:
[JavaScript](../javascript/src/functions/models.js), [Python](../python/src/models.py),
[.NET](../dotnet/Src/Models.cs). The engine reads named properties/attributes rather than provider JSON
or string-key response dictionaries. Optional values default to null/None; a status name takes precedence
over a code during outcome mapping, as before. Custom Python adapters must return `ParsedResponse`,
not the former dictionary.

This model is internal: do not serialize it into the endpoint response or log its fields. Public HTTP
responses still expose only the existing nonce/correlation/status or sanitized error contract.
Provider requests are serialized only when building the outbound HTTP body; incoming provider JSON
is parsed once and normalized inside its adapter. No serialization framework or provider-specific
class hierarchy is required.

Adapters require registration in the chosen runtime. Consult the selected adapter and its manifest
for required credentials and options: the manifest declares secret names and protocol mappings;
Expand Down Expand Up @@ -194,9 +212,15 @@ there is no implicit default or automatic failover. Request-body provider fields

The shared configuration readers are [JavaScript `readConfig`](../javascript/src/functions/config.js),
[Python `read_config`](../python/src/config.py), and [.NET `AppConfig.Read`](../dotnet/Src/AppConfig.cs).
They expose encryption, Key Vault and selected-provider settings, not caller-authentication settings.
They return named configuration objects for encryption and the selected provider, not caller-authentication
settings. Key Vault settings are read by JavaScript's configuration object and by the Python/.NET secret resolvers.
Provider-specific options remain ordinary app settings passed to the selected adapter.

JSON parsing and type checks stay at the request boundary. Downstream code uses `Envelope`,
`DeliveryContext` and `DispatchRequest` models (documented object shapes in JavaScript, dataclasses
in Python, and classes/records in .NET). Named .NET response records preserve the existing wire names
and optional-field omission. Object construction does not replace validation or coerce invalid input.

All customers call the same `POST /api/SendOtp` handler in their chosen language. Its registry selects
the configured adapter, which builds the provider's SMS or voice API call. Purchasing an unsupported
provider does not install an adapter: add and register that provider's adapter first. Purchase,
Expand Down
18 changes: 16 additions & 2 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,28 @@ script prerequisites separately; the application tests do not validate provision
## 2. Provision encryption and deployment trust

Use [local.settings.sample.json](local.settings.sample.json) as a starting point, replacing its
placeholders with the selected adapter's configuration. Keep local settings private and set the
same shared values in the Function App environment for deployment; the
placeholders with the selected adapter's configuration and choosing the matching worker runtime.
The sample's `UseDevelopmentStorage=true` is local-only and requires Azurite. Keep local settings
private; set application values in the Function App environment for deployment, configure its host
storage separately, and use a Key Vault reference instead of a local private-key value. The
[configuration catalog](CONTRACT.md#4-configuration-app-settings--env) is authoritative.

- Configure `EPP_DECRYPTION_KEY_PEM` through a Key Vault secret reference in Azure and give the caller
the matching public key. `EPP_ENCRYPTION_KEY_ID` is an optional advisory comparison after decryption,
not strict key pinning or multi-key lookup.

**Do not enable Entra access-token encryption for the Easy Auth resource app.** Leave its app
registration's `tokenEncryptionKeyId` as `null`; if previously configured, clear that property without
deleting its certificates or changing signing keys. This integration expects a signed bearer JWT,
not an encrypted access token that requires a separate private-key decryption step before validation.
After changing the registration, request a fresh token rather than reusing a cached encrypted token.
The resource is the endpoint app configured in Easy Auth's `clientId`/audience, not necessarily the
application requesting the token. The application code does not configure `tokenEncryptionKeyId`.

This is separate from the **required JWE encryption of `encryptedDeliveryContext`** in the request
body. Keep `EPP_DECRYPTION_KEY_PEM`; `EPP_ENCRYPTION_KEY_ID` only produces an advisory warning after
successful payload decryption and cannot cause a platform `401`.

Configure caller trust in the Function App's **App Service Authentication (Easy Auth)** platform
settings, not application environment variables:

Expand Down
10 changes: 4 additions & 6 deletions docs/local.settings.sample.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
{
"_comment": "Choose one runtime and a registered adapter. Credential values stay in Key Vault. Azure requires Easy Auth with authentication required and an allowed caller; configure it separately. Local hosts are unauthenticated and must stay on loopback.",
"_comment": "Local template: copy beside the chosen app's host.json and replace placeholders. Change node to python or dotnet-isolated for those runtimes. Start Azurite for UseDevelopmentStorage=true. Provider credentials stay in Key Vault. Local hosts have no Easy Auth; keep them on loopback. See README.md for Azure settings and optional values.",
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "node | dotnet-isolated | python",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "node",

"EPP_DECRYPTION_KEY_PEM": "<Key Vault secret reference in Azure; PEM or base64 PEM for local development>",
"EPP_ENCRYPTION_KEY_ID": "<encryption key ID; mismatch gives a nonfatal warning>",
"EPP_DECRYPTION_KEY_PEM": "<base64 of your local test private PEM>",

"EPP_PROVIDER_NAME": "<registered-adapter-id>",
"EPP_PROVIDER_ENDPOINT": "https://<provider-host>/<api-base-path>",
"EPP_PROVIDER_ACCOUNT_NAME": "<sender/account if required by the adapter; not an API key or credential ID>",
"_comment_provider_timeout": "Outbound provider HTTP timeout: ASCII decimal milliseconds, default 1500, cap 2500. Not a total invocation deadline; see CONTRACT.md. Provider URLs must be HTTPS; redirects are not followed.",
"EPP_PROVIDER_TIMEOUT_MS": "1500",

"KEY_VAULT_URL": "https://<provider-credential-vault>.vault.azure.net/"
Expand Down
Loading
Loading