Skip to content

Commit da71e65

Browse files
SciencePotatoHou Chi Chan
andauthored
docs: align SendOtp contract and customer guidance (#61)
Co-authored-by: Hou Chi Chan <houchichan@microsoft.com> Copilot-Session: 4fe53050-0d2b-4d05-bcc1-20f5b611b665
1 parent e9f4e68 commit da71e65

12 files changed

Lines changed: 268 additions & 105 deletions

‎README.md‎

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

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

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

7981
## Getting help
8082

‎TECHNICAL.md‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Authentication failures return **401 or 403**.
3636
## Resiliency and business continuity
3737

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

4141
## Implementations
4242

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

162-
The optional `riskContext` must be a JSON object or `null`. It is passed unchanged to provider
163-
adapters as `OtpDelivery.riskContext` in JavaScript, `OtpDelivery.RiskContext`
162+
The optional `riskContext` must be a JSON object or `null`. It can contain signals such as a
163+
scenario name or IP address. It is passed unchanged to provider adapters as
164+
`OtpDelivery.riskContext` in JavaScript, `OtpDelivery.RiskContext`
164165
(`Dictionary<string, JsonElement>`) in C#, and `OtpDelivery.risk_context`
165-
(`dict[str, object]`) in Python. No individual risk fields are required or interpreted by the
166-
shared handler. Risk data is not logged or returned in HTTP responses, and built-in adapters
167-
do not forward it to providers. Outbound risk-field mapping must be explicit and provider-specific.
166+
(`dict[str, object]`) in Python. The shared handler does not require or interpret individual
167+
fields. Risk data is not logged or returned in HTTP responses, and built-in adapters do not
168+
forward it to providers. A custom adapter must explicitly map each field it uses.
169+
170+
Current runtimes do not read an incoming `textToVoice` property. The Soprano adapters build their
171+
provider `text2voice` object from `message` and `locale`.
168172

169173
Validation reasons include:
170174

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

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

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

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

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

370376
JWE encryption and the nonce do not identify the caller. Anyone with the public key can encrypt.
371377
Provider credentials authenticate outbound calls, not incoming requests. Vault access uses managed identity.

‎docs/APPLICATION-INSIGHTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ and [Microsoft Entra-authenticated ingestion](https://learn.microsoft.com/azure/
119119
## Multiple regions
120120

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

124124
| Layout | Use |
125125
| --- | --- |

‎docs/CONTRACT.md‎

Lines changed: 127 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Authorization: Bearer <access-token>
4747
| --- | --- |
4848
| `Authorization` | Required bearer token. Easy Auth consumes it. |
4949
| `Content-Type` | Required. Use `application/json`. |
50-
| `x-ms-correlation-id` | Optional correlation fallback. |
50+
| `x-ms-correlation-id` | Optional correlation value. |
5151
| `x-ms-client-request-id` | Optional request identifier. |
5252

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

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

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

7285
### Encrypted delivery context
7386

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

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

78-
- **`nonce`** — Required. Returned unchanged after evaluation or provider acceptance.
79-
- **`phoneNumber`** — Required phone number.
80-
- **`message`** — Required localized message containing the passcode.
81-
- **`otp`** — Optional string containing the separate six-digit passcode. Preserve leading zeros.
82-
- **`locale`** — Optional voice locale.
83-
- **`extension`** — Optional office-voice data.
84-
- **`riskContext`** — Optional JSON object containing arbitrary risk fields; may be `null`.
85-
- **`textToVoice`** — Optional legacy speech data.
91+
```json
92+
{
93+
"nonce": "<nonce>",
94+
"phoneNumber": "+15551234567",
95+
"message": "Your verification code is 012345.",
96+
"otp": "012345",
97+
"locale": "en-US",
98+
"riskContext": {
99+
"scenario": "signIn",
100+
"ipAddress": "203.0.113.10"
101+
}
102+
}
103+
```
104+
105+
| Field | Required | Description |
106+
| --- | --- | --- |
107+
| `nonce` | Yes | Nonblank string returned unchanged after evaluation or provider acceptance. |
108+
| `phoneNumber` | Yes | Nonblank recipient phone number. |
109+
| `message` | Yes | Nonblank localized message containing the passcode. |
110+
| `otp` | No | Passcode supplied separately as a string. Leading zeros are preserved. |
111+
| `locale` | No | Voice locale. |
112+
| `extension` | No | Legacy office-voice data. The shared handler does not forward it to built-in providers. |
113+
| `riskContext` | No | JSON key-value object containing risk signals; may be `null`. |
86114

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

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

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

136165
Failures omit `nonce` and `providerStatus`.
@@ -142,15 +171,89 @@ Failures omit `nonce` and `providerStatus`.
142171
| Status | Description |
143172
| --- | --- |
144173
| `200 OK` | Evaluation completed or the provider accepted the request. |
145-
| `400 Bad Request` | Invalid request or encrypted content. |
174+
| `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. |
146175
| `401 Unauthorized` | Caller or provider authentication failed. |
147176
| `403 Forbidden` | Caller access was denied or the provider blocked the request. |
148177
| `429 Too Many Requests` | Provider throttling. |
149-
| `502 Bad Gateway` | Provider configuration, network, or response failure. |
178+
| `500 Internal Server Error` | Unexpected Function or provider response-processing failure. |
179+
| `502 Bad Gateway` | Missing or invalid provider endpoint, mismatched authentication mode, or failure to obtain credentials, reach the provider, or process its response. |
150180
| `504 Gateway Timeout` | Provider request timed out. |
151181

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

184+
## Error response
185+
186+
The Function returns JSON for application errors:
187+
188+
```json
189+
{
190+
"error": "provider_delivery_failed",
191+
"requestId": "<request ID>",
192+
"correlationId": "<correlation ID>"
193+
}
194+
```
195+
196+
| Field | Description |
197+
| --- | --- |
198+
| `error` | Stable public error category. |
199+
| `requestId` | Request identifier for safe log correlation. |
200+
| `reason` | Validation detail when it is safe to return. Usually present only for `400` responses. |
201+
| `correlationId` | Correlation value when available. |
202+
203+
Error categories are:
204+
205+
- `bad_request` for invalid request data or an incomplete decrypted delivery context.
206+
- `decryption_failed` when the encrypted delivery context cannot be decrypted or parsed.
207+
- `provider_delivery_failed` for provider selection, configuration, credentials, transport, or
208+
mapped provider-response failures.
209+
- `delivery_failed` for an unexpected handler failure.
210+
211+
These JSON errors come from the SendOtp handler. Easy Auth can reject a request before the handler
212+
runs. The .NET Functions host can also reject a request while binding its input, before the handler
213+
runs. Those responses can have a different format. When `reason` is present, it is a fixed
214+
validation message rather than an exception message.
215+
216+
Unavailable provider credentials, for example, return `502 Bad Gateway`. The safe diagnostic
217+
detail is recorded as
218+
`failureStage=provider_credentials` and `failureReason=credential_unavailable` in application
219+
logs, not exposed in the response.
220+
221+
### Diagnose a credential failure
222+
223+
When a live request cannot obtain provider credentials, current packages emit the following events:
224+
225+
| Signal | Meaning |
226+
| --- | --- |
227+
| `request_received` | The Function received the CYOT request. |
228+
| `envelope_validated` or `payload_validated` | The outer request passed validation. |
229+
| `delivery_context_decrypted` | The encrypted delivery context was decrypted. |
230+
| `provider_selected` | The configured provider was selected. |
231+
| `provider_credential_resolution_started` | The Function started resolving provider credentials. |
232+
| `request_failed` with credential stage, unavailable reason, and HTTP `502` | Credential resolution failed before the provider request started. |
233+
| `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. |
234+
235+
This pattern points to provider authentication or credential configuration, not envelope validation,
236+
decryption, or provider delivery. Check the selected provider's Key Vault secrets or OAuth managed
237+
identity, federation, tenant, scope, and permissions.
238+
239+
Field names and export shapes vary by runtime. JavaScript uses fields such as `failureStage`,
240+
`failureReason`, and `httpStatus`; .NET commonly exports `FailureStage`, `FailureReason`, and
241+
`HttpStatus`; Python logging exporters can place the same values in extra properties. Confirm the
242+
deployed shape through [runtime-specific log discovery](MONITORING.md#runtime-specific-log-discovery)
243+
and the [application log reference](LOGGING.md).
244+
245+
Current packages resolve credentials during live requests and do not emit
246+
`credential_refresh_failed`. Older packages can emit different events. Confirm the deployed
247+
package version and use its matching documentation before comparing sequences; upgrade only
248+
through the normal reviewed deployment process.
249+
250+
Use the `ResultCode` field on the workspace `AppRequests` row together with correlated
251+
`request_failed` and `request_completed` application events. The request telemetry records the
252+
observed HTTP transport result; application events record handler progress. Filters, sampling, and
253+
ingestion can omit an event, so a missing trace does not prove that the handler skipped that stage.
254+
See [provider credential acquisition fails](../setup/docs/Troubleshooting.md#provider-credential-acquisition-fails)
255+
for corrective steps.
256+
154257
## Related guidance
155258

156259
<a id="runtime-binding-differences"></a>

‎docs/COST-CALCULATOR.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ The guide separates the estimate into two parts:
1313
## 1. Estimate telecom-provider costs
1414

1515
> [!IMPORTANT]
16-
> This is not a billing report. Confirm retries, fallback, message segments, destinations, taxes,
17-
> fees, and pricing with the provider.
16+
> This is not a billing report. Confirm retries, secondary-provider attempts, message segments,
17+
> destinations, taxes, fees, and pricing with the provider.
1818
1919
| Step | Task |
2020
| --- | --- |
@@ -202,7 +202,7 @@ Estimated SMS/voice requests = 10,000
202202

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

207207
<a id="confirm-the-final-estimate-with-your-provider"></a>
208208
### Confirm provider rates
@@ -265,7 +265,7 @@ Include these resources:
265265
| Function App | Hosts the application and its HTTPS endpoint. | There is no separate base charge; compute is charged through the selected Functions hosting plan. |
266266
| Application Insights and Log Analytics | Provides operational monitoring, request diagnostics, provider latency, failure data, and troubleshooting telemetry. | Telemetry volume and retention. |
267267
| Storage account | Stores Function host information and the deployment package. | Stored capacity, transactions, and LRS versus ZRS replication. Zone-redundant deployments require ZRS storage. |
268-
| Key Vault | Protects provider credentials, secrets, and the encryption certificate. | Secret and certificate operations. This is normally a small cost because credentials are cached. |
268+
| 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. |
269269
| 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. |
270270
| 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. |
271271

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

282282
Use adjusted request volume consistently across the estimate:
283283

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

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

0 commit comments

Comments
 (0)