Skip to content
Closed
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
49 changes: 31 additions & 18 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,23 @@ JWE; the cleartext envelope carries routing/scheduling only.
| Header | Notes |
|--------|-------|
| `Authorization` | `Bearer <Entra token>` (audience = `EPP_EXPECTED_AUDIENCE`) |
| `User-Agent` | e.g. `Microsoft-AzureMFA-SAS-CYOT/1.0` (logged) |
| `User-Agent` | e.g. `Microsoft-AzureMFA-SAS-CYOT/1.0`; not 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` |
| `type` | ✅ | envelope contract version, `microsoft.mfa.otpDeliver.v1`; anything else → `400` (a version we don't know may reuse these field names with different meanings) |
| `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; `<= 0` is **logged as a warning** — the delivery still proceeds |
| `ttlSeconds` | | optional; when present must be a positive JSON integer (null, booleans, strings and fractions are rejected); `<= 0` → `400`, **nothing is delivered** |
| `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`.
`type` other than `microsoft.mfa.otpDeliver.v1` → `400`. `channel` not in `{1,2}`/`{sms,voice}` → `400`. `mode` not in `{1,2}`/`{live,evaluation}` → `400`. Missing/empty `encryptedDeliveryContext` → `400`. `ttlSeconds <= 0` → `400`.

### `encryptedDeliveryContext` (JWE)

Expand All @@ -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**. For `voice`, the passcode digits are spaced so TTS reads them individually |
| `message` | ✅ | fully rendered + localized text; **contains the passcode**. The caller supplies voice digit spacing. Forward the text unchanged; do not guess which number is the passcode |
| `extension` | | office voice only |
| `locale` | | selects TTS voice for the voice channel |
| `riskContext` | | `CyotRiskContext` (scenario, familiarity flags, ip/asn/geo, ja4/ja4h, …) |
Expand Down Expand Up @@ -117,17 +117,17 @@ Set by provisioning. **Identical names across all languages.**
| Key | Purpose |
|-----|---------|
| `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_ENDPOINT` | absolute HTTPS provider base URL (one provider is active per deployment) |
| `EPP_PROVIDER_ACCOUNT_NAME` | sender / source id presented to the provider (unused by Soprano, whose omnimsg endpoint takes the sender from the account provisioning) |
| `EPP_PROVIDER_TIMEOUT_MS` | outbound call timeout (default 1500, capped at 2500); not an end-to-end invocation deadline |
| `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_ENCRYPTION_KEY_ID` | legacy advisory setting; the configured PEM decrypts the JWE. Key IDs and headers are not logged |
| `EPP_REQUIRE_AUTH` | `true` → validate the Entra token in-process. **Recommended `true` in every deployment**; Easy Auth is the primary gate, this is the backstop |
| `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`/`azp` to admit — Microsoft's app `25ec60fa-f18d-41a4-b398-50044c90ce13`. Enforced by Easy Auth (`403`) and, when `EPP_REQUIRE_AUTH=true`, against the token's own claim (`401`) |
| `EPP_LOG_PLAINTEXT` | **diagnostics only** — `true` writes the phone number and passcode to the log. Never enable in production |
| `EPP_LOG_PLAINTEXT` | obsolete and ignored; plaintext logging is not supported |
| `KEY_VAULT_URL` | Key Vault URI (provider API keys) |
| `AZURE_CLIENT_ID` | set for a user-assigned managed identity |

Expand All @@ -142,29 +142,42 @@ User* role). Never in code or config.
- **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). 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**.
- **Privacy** — never log phone numbers, passcodes, nonce values, tokens, keys, JWE contents,
raw exceptions or provider bodies. There is no plaintext diagnostic override. Logs contain
request IDs, safe correlation IDs and fixed status/timing fields only. Non-GUID trace IDs are
hashed for logging; original IDs and the required nonce echo remain unchanged on the wire.
These pseudonymous traces still require normal retention and access controls.
- **Auth** — **Easy Auth must be ON** (`unauthenticatedClientAction=Return401`, `allowedApplications`
pinned to Microsoft's app); the trigger is `authLevel: anonymous`, so it is the primary gate.
`EPP_EXPECTED_CLIENT_ID` mismatches return `403`. Deployments should **also** set
`EPP_EXPECTED_CLIENT_ID` mismatches return `403`. Azure deployments **must also** set
`EPP_REQUIRE_AUTH=true` to 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).
- **Synchronous acceptance** — await the provider response before replying to SAS. Only a mapped
`Continue` outcome returns `200` with the nonce; provider rejection, auth failure, network failure,
or timeout returns non-2xx so SAS can fall back to native delivery.
An unsuccessful provider HTTP response must not become `Continue` because its body contains
a success-looking status. Provider acceptance is not proof of final handset delivery.
- **Timeout limitations** — inbound authentication, decryption, Key Vault and OAuth acquisition
are outside the outbound HTTP timeout. Python `requests` uses connect/read inactivity timeouts,
not a hard total elapsed deadline. A 2500 ms cap alone does not guarantee a 3.2-second response.
A timed-out POST may already have been accepted by a provider; do not blindly retry it.

---

## 6. Conformance test scenarios

Every implementation ships tests covering at least:
Tests are organized by behavior: envelope/decryption, provider dispatch, and HTTP/authentication.
Small tables cover input categories; avoid repeating the same matrix at every layer.
Every implementation covers:

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.
6. Envelope validation: `400` on invalid JSON, an unrecognised `type`, unsupported `channel`,
unsupported `mode`, missing `encryptedDeliveryContext`, `ttlSeconds <= 0`, 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.
Expand Down
96 changes: 96 additions & 0 deletions docs/SOPRANO-SERVICE-PRINCIPAL-SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Soprano QA: service-principal setup and bearer authentication

This runbook describes the QA test application, not the production CYOT caller identity
or a confirmed per-customer billing architecture. Never put client secrets or access tokens here.

## Last verified QA state (2026-09-08)

- Token acquisition succeeded with v2.0 `aud`, `iss`, and `azp` matching the QA guide.
- QA4 rejected the Bearer request with HTTP `401`, error `401101` ("User cannot be authenticated").
- API-key SMS dispatch returned `201 ENROUTE`; this is acceptance, not proof of handset delivery.
- Ask Soprano to inspect authentication logs and confirm the test caller's MEMS account mapping
and any required app role. Mapping is a likely cause, not a proven diagnosis of the 401.
- The setup steps below apply only if the enterprise application is missing; do not recreate
an existing service principal as a way to fix an API-level authentication rejection.

## Why you're seeing this

If the QA client is missing its enterprise application in the provider tenant, Entra returns:

```
AADSTS7000229: The client application 89c1e810-568e-4398-b80c-967772eaca0f
is missing service principal in the tenant 801bae25-4443-4a29-9e56-9d1cf22ff819.
```

This is an **Entra ID** response from **your** tenant, returned *before* the request ever
reaches your API. Microsoft's app is a multitenant application; for Entra to issue a token
that targets your tenant, a **service principal** (enterprise-application entry) for that app
must exist **in your directory**. Authorizing the app ID as an accepted caller in your API
config is a separate thing and does not create this object.

This is the exact scenario documented by Microsoft:
<https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/create-service-principal-cross-tenant>
(the link embedded in the `AADSTS7000229` error itself).

## Identity used by this QA test

Create one service principal for the test application in the provider tenant. This does
not establish whether production uses shared or per-customer callers. Confirm the production
identity and Marketplace-subscription mapping with the service owners separately; `azp`
is an application ID, not a Marketplace subscription ID.

| Value | ID |
| --- | --- |
| Application (client) ID / `azp` | `89c1e810-568e-4398-b80c-967772eaca0f` |
| Your tenant (where the SP must be created) | `801bae25-4443-4a29-9e56-9d1cf22ff819` |
| Your API app ID (token audience) | `32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe` |

## Prerequisites

- Sign in to tenant `801bae25-4443-4a29-9e56-9d1cf22ff819`.
- Role: **Cloud Application Administrator** or **Application Administrator**.

## Do one of the following (any single method is enough)

### Option A — Azure CLI

```bash
az login --tenant 801bae25-4443-4a29-9e56-9d1cf22ff819
az ad sp create --id 89c1e810-568e-4398-b80c-967772eaca0f
```

### Option B — Microsoft Graph PowerShell

```powershell
Connect-MgGraph -TenantId 801bae25-4443-4a29-9e56-9d1cf22ff819 -Scopes "Application.ReadWrite.All"
New-MgServicePrincipal -AppId 89c1e810-568e-4398-b80c-967772eaca0f
```

### Option C — Microsoft Graph REST

```http
POST https://graph.microsoft.com/v1.0/servicePrincipals
Content-type: application/json

{ "appId": "89c1e810-568e-4398-b80c-967772eaca0f" }
```

## Verify

The service principal now appears under **Entra ID > Enterprise applications** when you
search for app ID `89c1e810-568e-4398-b80c-967772eaca0f`. Depending on the API's assignment
policy, app-role assignment or administrator consent may also be required. A role-less
app-only token can be issued for an API using an application-ID allowlist; it does not
by itself prove the caller is authorized by MEMS. The API must validate signature, lifetime,
`aud`, `iss`, and the admitted caller, plus any required application roles.

## After you're done

Let the Microsoft team know and we will re-run the token request; it should return a JWT
instead of `AADSTS7000229`, and we will send a live OTP to the whitelisted number to
confirm end to end.

## Reference

- Create an enterprise application from a multitenant application —
<https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/create-service-principal-cross-tenant>
13 changes: 10 additions & 3 deletions docs/local.settings.sample.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"_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.",
"_comment": "Shared settings for all three runtimes. Provider secrets stay in Key Vault. See CONTRACT.md for configuration and security requirements.",
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "node | dotnet-isolated | python",
Expand All @@ -17,10 +17,17 @@
"EPP_PROVIDER_NAME": "<infobip | telesign | sinch | soprano>",
"EPP_PROVIDER_ENDPOINT": "https://<provider-base-url>",
"EPP_PROVIDER_ACCOUNT_NAME": "<your sender / source id>",
"_comment_provider_timeout": "Outbound provider timeout in milliseconds (default 1500, cap 2500). Not an end-to-end deadline; Key Vault/OAuth are outside it and Python uses connect/read inactivity timeouts. See CONTRACT.md.",
"EPP_PROVIDER_TIMEOUT_MS": "1500",

"_comment_log_plaintext": "DIAGNOSTICS ONLY. true writes the phone number and passcode to the log. Never enable in production.",
"EPP_LOG_PLAINTEXT": "false",
"_comment_provider_oauth": "Optional outbound OAuth: EPP_PROVIDER_CLIENT_ID is the caller, EPP_PROVIDER_TENANT_ID the issuer tenant, and EPP_PROVIDER_SCOPE the API audience. Use managed-identity federation OR a client secret from Key Vault. Never forward the inbound token.",
"EPP_PROVIDER_AUTH_MODE": "apiKey",
"EPP_PROVIDER_TENANT_ID": "<provider tenant id>",
"EPP_PROVIDER_CLIENT_ID": "<our app registration client id>",
"EPP_PROVIDER_SCOPE": "<provider-app-id>/.default",
"EPP_PROVIDER_MI_CLIENT_ID": "<user-assigned managed identity client id (federated on our app)>",
"EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE": "api://AzureADTokenExchange",
"EPP_PROVIDER_CLIENT_SECRET_NAME": "<Key Vault secret name holding our client secret (only if not using a managed identity)>",

"KEY_VAULT_URL": "https://<your-key-vault>.vault.azure.net/"
}
Expand Down
Loading
Loading