Skip to content
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Guided setup deploys **one regional endpoint** using JavaScript, C#, or Python.
*Easy Auth checks the Microsoft caller before the Function decrypts the request.
Live success confirms provider acceptance. Confirm delivery separately. Evaluation validates the
request without sending a message.
The diagram is a topology overview; current packages read API-key secrets for each live request,
while Azure Identity owns OAuth token caching.
See [architecture details](TECHNICAL.md#single-region-architecture).
[Open the full-size diagram](docs/images/single-region-architecture.png).*

Expand Down Expand Up @@ -74,7 +76,7 @@ Before live use, review [production readiness](TECHNICAL.md#production-readiness
### Advanced topics

- [Multi-region routing](docs/FRONTDOOR.md)
- [Provider fallback](docs/MULTI-PROVIDER-IMPLEMENTATION.md)
- [Secondary-provider design](docs/MULTI-PROVIDER-IMPLEMENTATION.md)

## Getting help

Expand Down
30 changes: 18 additions & 12 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Authentication failures return **401 or 403**.
## Resiliency and business continuity

The [Azure Front Door design](docs/FRONTDOOR.md) adds regional origins behind one public URL.
It covers capacity, shared keys, dependencies, readiness, failover drills, and rollback.
It covers capacity, shared keys, dependencies, readiness, regional recovery drills, and rollback.

## Implementations

Expand Down Expand Up @@ -159,12 +159,16 @@ provider-neutral delivery object (`OtpDelivery.otp` in JavaScript/Python, `OtpDe
It is not converted to a number or extracted from `message`, and is not logged or returned in
the HTTP response. Existing built-in provider message and voice mappings remain unchanged.

The optional `riskContext` must be a JSON object or `null`. It is passed unchanged to provider
adapters as `OtpDelivery.riskContext` in JavaScript, `OtpDelivery.RiskContext`
The optional `riskContext` must be a JSON object or `null`. It can contain signals such as a
scenario name or IP address. It is passed unchanged to provider adapters as
`OtpDelivery.riskContext` in JavaScript, `OtpDelivery.RiskContext`
(`Dictionary<string, JsonElement>`) in C#, and `OtpDelivery.risk_context`
(`dict[str, object]`) in Python. No individual risk fields are required or interpreted by the
shared handler. Risk data is not logged or returned in HTTP responses, and built-in adapters
do not forward it to providers. Outbound risk-field mapping must be explicit and provider-specific.
(`dict[str, object]`) in Python. The shared handler does not require or interpret individual
fields. Risk data is not logged or returned in HTTP responses, and built-in adapters do not
forward it to providers. A custom adapter must explicitly map each field it uses.

Current runtimes do not read an incoming `textToVoice` property. The Soprano adapters build their
provider `text2voice` object from `message` and `locale`.

Validation reasons include:

Expand Down Expand Up @@ -199,7 +203,8 @@ The shared outcomes are `Continue`, `Block`, and `Fail`.
| Provider `429` | `429` |
| Provider `401` or `403` | `401` |
| Other provider `4xx` | `400` |
| Missing configuration, network failure, or unknown response | `502` |
| Missing or unregistered provider selection | `400` |
| Missing or invalid endpoint, authentication-mode mismatch, credential or network failure, or unknown response | `502` |
| Provider timeout | `504` |

Unknown or incomplete provider responses fail closed. Check a timed-out live request before
Expand Down Expand Up @@ -247,13 +252,13 @@ The provider timeout defaults to `1500` and is capped at `2500` milliseconds.

Each live request uses the endpoint for its selected channel. Endpoints must be absolute HTTPS
URLs with a hostname, valid port, no user information, and no fragment. Redirects are not followed.
Each channel uses only its configured endpoint. Provider failover requires the
[advanced fallback design](docs/MULTI-PROVIDER-IMPLEMENTATION.md).
Each channel uses only its configured endpoint. A secondary provider requires the
[advanced secondary-provider design](docs/MULTI-PROVIDER-IMPLEMENTATION.md).

All runtimes acquire provider credentials on demand for each live request:

- **API keys:** read the required secret values from Key Vault each time. Failed reads never
fall back to a previous secret bundle.
- **API keys:** read the required secret values from Key Vault each time. A failed read does not
reuse a previous secret bundle.
- **OAuth:** reuse Azure Identity SDK credential instances and call their token API for each
request. The SDK owns token caching and renewal; a usable cached token does not require
another token exchange. Managed-identity assertions are obtained only when the SDK needs them.
Expand Down Expand Up @@ -365,7 +370,8 @@ Before live use:
**Easy Auth is the caller-authentication gate before the Function runs.**
Follow the [exact trust checklist](docs/ONBOARDING.md#manual-deployment-trust-checklist) for HTTPS,
issuer, audience, caller allowlist, and unauthenticated rejection. Never disable it or exempt SendOtp.
There is no handler token validator or function-key fallback.
The handler does not validate bearer tokens itself and does not accept a function key as an
alternative.

JWE encryption and the nonce do not identify the caller. Anyone with the public key can encrypt.
Provider credentials authenticate outbound calls, not incoming requests. Vault access uses managed identity.
Expand Down
2 changes: 1 addition & 1 deletion docs/APPLICATION-INSIGHTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ and [Microsoft Entra-authenticated ingestion](https://learn.microsoft.com/azure/
## Multiple regions

Each region has its own Function App and identity. Validate telemetry for every origin.
Application Insights does not provide application routing or failover.
Application Insights does not route traffic or implement regional recovery.

| Layout | Use |
| --- | --- |
Expand Down
151 changes: 127 additions & 24 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Authorization: Bearer <access-token>
| --- | --- |
| `Authorization` | Required bearer token. Easy Auth consumes it. |
| `Content-Type` | Required. Use `application/json`. |
| `x-ms-correlation-id` | Optional correlation fallback. |
| `x-ms-correlation-id` | Optional correlation value. |
| `x-ms-client-request-id` | Optional request identifier. |

The Function does not forward the bearer token to the phone provider.
Expand All @@ -57,39 +57,68 @@ The Function does not forward the bearer token to the phone provider.
<a id="epp-request-body-envelope-cleartext-envelope"></a>
<a id="epp-request-body"></a>

- **`type`** — Required string. Use `microsoft.mfa.otpDeliver.v1`.
- **`channel`** — Required integer or string. `1` or `"sms"` for SMS. `2` or `"voice"` for voice.
- **`mode`** — Required integer or string. `1` or `"live"` to send. `2` or `"evaluation"`
for no-send validation.
- **`encryptedDeliveryContext`** — Required compact JWE string.
- **`tenantId`** — Optional routing metadata.
- **`correlationId`** — Optional sign-in correlation value.
- **`ttlSeconds`** — Optional positive integer up to `2147483647`.
```json
{
"type": "microsoft.mfa.otpDeliver.v1",
"channel": 1,
"mode": 1,
"encryptedDeliveryContext": "<compact JWE>",
"tenantId": "<tenant ID>",
"correlationId": "<correlation ID>",
"ttlSeconds": 30
}
```

Send integer channel, mode, and TTL values in their documented forms for consistent behavior across
all runtimes. See [request validation and runtime differences](../TECHNICAL.md#request-validation-and-runtime-differences).
| Field | Required | Description |
| --- | --- | --- |
| `type` | Yes | Use `microsoft.mfa.otpDeliver.v1`. |
| `channel` | Yes | `1` or `"sms"` for SMS. `2` or `"voice"` for voice. |
| `mode` | Yes | `1` or `"live"` to send. `2` or `"evaluation"` for no-send validation. |
| `encryptedDeliveryContext` | Yes | Compact JWE containing the delivery data. |
| `tenantId` | No | Tenant metadata supplied by Microsoft Entra ID. The shared handler does not use it to select a provider. |
| `correlationId` | No | Sign-in correlation value. |
| `ttlSeconds` | No | Positive integer up to `2147483647`. The sample validates this value but does not enforce passcode expiry or a delivery deadline. |

Use the documented channel and mode values. When supplying `ttlSeconds`, use an integer. See
[request validation and runtime differences](../TECHNICAL.md#request-validation-and-runtime-differences).

### Encrypted delivery context

<a id="encrypteddeliverycontext-jwe"></a>

The JWE uses `RSA-OAEP-256` and `A256GCM`. After decryption, the JSON contains:

- **`nonce`** — Required. Returned unchanged after evaluation or provider acceptance.
- **`phoneNumber`** — Required phone number.
- **`message`** — Required localized message containing the passcode.
- **`otp`** — Optional string containing the separate six-digit passcode. Preserve leading zeros.
- **`locale`** — Optional voice locale.
- **`extension`** — Optional office-voice data.
- **`riskContext`** — Optional JSON object containing arbitrary risk fields; may be `null`.
- **`textToVoice`** — Optional legacy speech data.
```json
{
"nonce": "<nonce>",
"phoneNumber": "+15551234567",
"message": "Your verification code is 012345.",
"otp": "012345",
"locale": "en-US",
"riskContext": {
"scenario": "signIn",
"ipAddress": "203.0.113.10"
}
}
```

| Field | Required | Description |
| --- | --- | --- |
| `nonce` | Yes | Nonblank string returned unchanged after evaluation or provider acceptance. |
| `phoneNumber` | Yes | Nonblank recipient phone number. |
| `message` | Yes | Nonblank localized message containing the passcode. |
| `otp` | No | Passcode supplied separately as a string. Leading zeros are preserved. |
| `locale` | No | Voice locale. |
| `extension` | No | Legacy office-voice data. The shared handler does not forward it to built-in providers. |
| `riskContext` | No | JSON key-value object containing risk signals; may be `null`. |

Microsoft Entra ID supplies `otp` for SMS and voice, with a generated test code in evaluation
mode. It belongs inside the encrypted delivery context, not the outer request body. Requests
that omit it or set it to `null` remain supported.

`riskContext` has no fixed field schema. Unknown keys, nested objects and arrays, and values such
as `false`, `0`, and `null` are preserved. Missing fields are not filled with defaults.
`riskContext` can include signals such as a scenario name or IP address. The shared handler
preserves the supplied object but does not interpret, log, return, or forward its fields to the
built-in providers. A custom adapter must explicitly map any fields it needs.

See [JWE validation and key handling](../TECHNICAL.md#jwe-validation-and-key-handling) for limits,
algorithms, key settings, and runtime behavior.
Expand Down Expand Up @@ -130,7 +159,7 @@ Content-Type: application/json
| Field | Description |
| --- | --- |
| `nonce` | Original decrypted nonce. |
| `correlationId` | Request correlation value or generated fallback. |
| `correlationId` | Request correlation value or generated request ID. |
| `providerStatus` | `"accepted"` for evaluation or recognized live acceptance. |

Failures omit `nonce` and `providerStatus`.
Expand All @@ -142,15 +171,89 @@ Failures omit `nonce` and `providerStatus`.
| Status | Description |
| --- | --- |
| `200 OK` | Evaluation completed or the provider accepted the request. |
| `400 Bad Request` | Invalid request or encrypted content. |
| `400 Bad Request` | Invalid request or encrypted content, a missing or unrecognized provider setting, or a provider `4xx` response that maps to `400`. Provider authentication failures and throttling map to the statuses listed separately below. |
| `401 Unauthorized` | Caller or provider authentication failed. |
| `403 Forbidden` | Caller access was denied or the provider blocked the request. |
| `429 Too Many Requests` | Provider throttling. |
| `502 Bad Gateway` | Provider configuration, network, or response failure. |
| `500 Internal Server Error` | Unexpected Function or provider response-processing failure. |
| `502 Bad Gateway` | Missing or invalid provider endpoint, mismatched authentication mode, or failure to obtain credentials, reach the provider, or process its response. |
| `504 Gateway Timeout` | Provider request timed out. |

Check a timed-out live request before retrying. The provider might already have accepted it.

## Error response
Comment thread
SciencePotato marked this conversation as resolved.

The Function returns JSON for application errors:

```json
{
"error": "provider_delivery_failed",
"requestId": "<request ID>",
"correlationId": "<correlation ID>"
}
```

| Field | Description |
| --- | --- |
| `error` | Stable public error category. |
| `requestId` | Request identifier for safe log correlation. |
| `reason` | Validation detail when it is safe to return. Usually present only for `400` responses. |
| `correlationId` | Correlation value when available. |

Error categories are:

- `bad_request` for invalid request data or an incomplete decrypted delivery context.
- `decryption_failed` when the encrypted delivery context cannot be decrypted or parsed.
- `provider_delivery_failed` for provider selection, configuration, credentials, transport, or
mapped provider-response failures.
- `delivery_failed` for an unexpected handler failure.

These JSON errors come from the SendOtp handler. Easy Auth can reject a request before the handler
runs. The .NET Functions host can also reject a request while binding its input, before the handler
runs. Those responses can have a different format. When `reason` is present, it is a fixed
validation message rather than an exception message.

Unavailable provider credentials, for example, return `502 Bad Gateway`. The safe diagnostic
detail is recorded as
`failureStage=provider_credentials` and `failureReason=credential_unavailable` in application
logs, not exposed in the response.

### Diagnose a credential failure

When a live request cannot obtain provider credentials, current packages emit the following events:

| Signal | Meaning |
| --- | --- |
| `request_received` | The Function received the CYOT request. |
| `envelope_validated` or `payload_validated` | The outer request passed validation. |
| `delivery_context_decrypted` | The encrypted delivery context was decrypted. |
| `provider_selected` | The configured provider was selected. |
| `provider_credential_resolution_started` | The Function started resolving provider credentials. |
| `request_failed` with credential stage, unavailable reason, and HTTP `502` | Credential resolution failed before the provider request started. |
| `response_prepared`, then `request_completed` with HTTP `502` | The handler prepared a `provider_delivery_failed` response with HTTP `502`. These events do not confirm that the caller received it. |

This pattern points to provider authentication or credential configuration, not envelope validation,
decryption, or provider delivery. Check the selected provider's Key Vault secrets or OAuth managed
identity, federation, tenant, scope, and permissions.

Field names and export shapes vary by runtime. JavaScript uses fields such as `failureStage`,
`failureReason`, and `httpStatus`; .NET commonly exports `FailureStage`, `FailureReason`, and
`HttpStatus`; Python logging exporters can place the same values in extra properties. Confirm the
deployed shape through [runtime-specific log discovery](MONITORING.md#runtime-specific-log-discovery)
and the [application log reference](LOGGING.md).

Current packages resolve credentials during live requests and do not emit
`credential_refresh_failed`. Older packages can emit different events. Confirm the deployed
package version and use its matching documentation before comparing sequences; upgrade only
through the normal reviewed deployment process.

Use the `ResultCode` field on the workspace `AppRequests` row together with correlated
`request_failed` and `request_completed` application events. The request telemetry records the
observed HTTP transport result; application events record handler progress. Filters, sampling, and
ingestion can omit an event, so a missing trace does not prove that the handler skipped that stage.
See [provider credential acquisition fails](../setup/docs/Troubleshooting.md#provider-credential-acquisition-fails)
for corrective steps.

## Related guidance

<a id="runtime-binding-differences"></a>
Expand Down
14 changes: 7 additions & 7 deletions docs/COST-CALCULATOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ The guide separates the estimate into two parts:
## 1. Estimate telecom-provider costs

> [!IMPORTANT]
> This is not a billing report. Confirm retries, fallback, message segments, destinations, taxes,
> fees, and pricing with the provider.
> This is not a billing report. Confirm retries, secondary-provider attempts, message segments,
> destinations, taxes, fees, and pricing with the provider.

| Step | Task |
| --- | --- |
Expand Down Expand Up @@ -202,7 +202,7 @@ Estimated SMS/voice requests = 10,000

Apply the provider's destination-specific rates to those estimated requests. Use separate
rows when rates differ by destination, sender type, route, or message category. Include a
contingency for growth, retries, fallback behavior, and seasonal peaks.
contingency for growth, retries, approved secondary-provider behavior, and seasonal peaks.

<a id="confirm-the-final-estimate-with-your-provider"></a>
### Confirm provider rates
Expand Down Expand Up @@ -265,7 +265,7 @@ Include these resources:
| Function App | Hosts the application and its HTTPS endpoint. | There is no separate base charge; compute is charged through the selected Functions hosting plan. |
| Application Insights and Log Analytics | Provides operational monitoring, request diagnostics, provider latency, failure data, and troubleshooting telemetry. | Telemetry volume and retention. |
| Storage account | Stores Function host information and the deployment package. | Stored capacity, transactions, and LRS versus ZRS replication. Zone-redundant deployments require ZRS storage. |
| Key Vault | Protects provider credentials, secrets, and the encryption certificate. | Secret and certificate operations. This is normally a small cost because credentials are cached. |
| Key Vault | Protects provider credentials, secrets, and the encryption certificate. | Secret and certificate operations. Current packages read API-key secrets on each live request; OAuth token caching is owned by the Azure Identity SDK. |
| Outbound bandwidth | Carries API requests and responses between Azure Functions and the provider. | Outbound data after the applicable Azure allowance. This is usually small for provider API payloads. |
| Traffic Manager or equivalent global routing | Routes traffic in an optional multi-region disaster-recovery deployment. | DNS queries or requests, depending on the selected routing service. |

Expand All @@ -274,15 +274,15 @@ Collect these inputs before creating your estimate:
- Monthly SMS and voice authentication requests, including authentication, enrollment,
recovery, and test traffic.
- Average Function execution duration, including provider API latency.
- Expected retry and failover allowance.
- Expected retry and regional-recovery allowance.
- Function memory allocation and operational logging generated per request.
- Required region, availability-zone resilience, and regional disaster recovery.
- Your Azure pricing, discounts, free grants, taxes, and currency.

Use adjusted request volume consistently across the estimate:

```text
Adjusted monthly requests = monthly authentication requests x retry/failover multiplier
Adjusted monthly requests = monthly authentication requests x retry/recovery multiplier
Execution GB-seconds = adjusted monthly requests x execution seconds x memory in GB
Monitoring data in GB = adjusted monthly requests x logging KB per request / 1,048,576
Estimated Azure total = Functions + monitoring + storage + Key Vault
Expand Down Expand Up @@ -312,5 +312,5 @@ the Function cold-start portion. Zone redundancy protects against a datacenter-z
failure within one region; it does not provide regional disaster recovery.

For a multi-region design, also account for globally unique idempotency, provider-aware
failover, shared fraud and spend limits, and an emergency stop. Autoscaling Azure compute
regional recovery, shared fraud and spend limits, and an emergency stop. Autoscaling Azure compute
must not create unlimited telecom spend.
Loading
Loading