diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 233e3f2..5c062ff 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -24,7 +24,7 @@ JWE; the cleartext envelope carries routing/scheduling only. | Header | Notes | |--------|-------| | `Authorization` | `Bearer ` (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`) | @@ -32,15 +32,15 @@ JWE; the cleartext envelope carries routing/scheduling only. | 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) @@ -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, …) | @@ -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 | @@ -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. diff --git a/docs/SOPRANO-SERVICE-PRINCIPAL-SETUP.md b/docs/SOPRANO-SERVICE-PRINCIPAL-SETUP.md new file mode 100644 index 0000000..afc66ea --- /dev/null +++ b/docs/SOPRANO-SERVICE-PRINCIPAL-SETUP.md @@ -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: + +(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 — + diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index 2181368..d2b1502 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -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", @@ -17,10 +17,17 @@ "EPP_PROVIDER_NAME": "", "EPP_PROVIDER_ENDPOINT": "https://", "EPP_PROVIDER_ACCOUNT_NAME": "", + "_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": "", + "EPP_PROVIDER_CLIENT_ID": "", + "EPP_PROVIDER_SCOPE": "/.default", + "EPP_PROVIDER_MI_CLIENT_ID": "", + "EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE": "api://AzureADTokenExchange", + "EPP_PROVIDER_CLIENT_SECRET_NAME": "", "KEY_VAULT_URL": "https://.vault.azure.net/" } diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index c201a67..cd60087 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -1,5 +1,5 @@ +using System.Diagnostics; using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; @@ -7,14 +7,8 @@ namespace Epp.Otp; -// HTTP trigger: POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the -// caller, parses the cleartext routing envelope, decrypts the JWE delivery context, dispatches to the -// provider, and echoes the nonce to prove decryption. Every line is tagged [EPP] so one filter pulls a -// whole delivery. public sealed class SendOtp { - private const string Tag = "[EPP]"; - private readonly DispatchEngine _engine; private readonly TokenValidator _tokens; private readonly JweDecryptor _decryptor; @@ -30,7 +24,7 @@ public SendOtp(DispatchEngine engine, TokenValidator tokens, JweDecryptor decryp _log = log; } - // Easy Auth has already validated the token; this records which identity arrived. + // Easy Auth has already validated the token; check its caller against the configured app. private static string? ReadCallerAppId(HttpRequest req) { var encoded = req.Headers["x-ms-client-principal"].FirstOrDefault(); @@ -54,46 +48,42 @@ public SendOtp(DispatchEngine engine, TokenValidator tokens, JweDecryptor decryp } } - // Left alone, TTS reads 641895 as "six hundred forty-one thousand...", which no user can type. - private static string? SpacePasscodeForVoice(string? message) => - string.IsNullOrEmpty(message) ? message : Regex.Replace(message, @"\b\d{4,8}\b", m => string.Join(" ", m.Value.ToCharArray())); - [Function("SendOtp")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "SendOtp")] HttpRequest req) { - var started = DateTimeOffset.UtcNow; + var started = Stopwatch.StartNew(); var requestId = Guid.NewGuid().ToString("n"); - var clientRequestId = req.Headers["x-ms-client-request-id"].FirstOrDefault() ?? requestId; - var headerCorrelationId = req.Headers["x-ms-correlation-id"].FirstOrDefault(); - var logPlaintext = string.Equals(_env.Get("EPP_LOG_PLAINTEXT"), "true", StringComparison.OrdinalIgnoreCase); - var expectedKeyId = _env.Get("EPP_ENCRYPTION_KEY_ID"); - var expectedClientId = _env.Get("EPP_EXPECTED_CLIENT_ID"); - - void Log(string label, object? value) => _log.LogInformation("{Tag} {Label}: {Value}", Tag, label.PadRight(18), value); - - _log.LogInformation("{Tag} ======== delivery received ========", Tag); - Log("invocation", requestId); - string? correlationId = null; + var evaluation = false; + var provider = "unknown"; + var logChannel = "unknown"; + var mode = "unknown"; + var status = 500; + var outcome = Outcome.Fail; + var nonceEcho = false; + var shutterProcessed = false; + + IActionResult Reply(int httpStatus, object body) + { + status = httpStatus; + return new ObjectResult(body) { StatusCode = httpStatus }; + } + try { + var clientRequestId = req.Headers["x-ms-client-request-id"].FirstOrDefault() ?? requestId; + correlationId = req.Headers["x-ms-correlation-id"].FirstOrDefault() ?? requestId; + provider = DispatchEngine.SafeProvider(_env.Get("EPP_PROVIDER_NAME")); + var expectedClientId = _env.Get("EPP_EXPECTED_CLIENT_ID"); var callerAppId = ReadCallerAppId(req); - Log("caller appid", callerAppId ?? "none (Easy Auth off, or called directly)"); if (callerAppId is not null && !string.IsNullOrEmpty(expectedClientId) && callerAppId != expectedClientId) - { - _log.LogError("{Tag} caller {Caller} is not {Expected}. Easy Auth allowedApplications is not doing its job.", - Tag, callerAppId, expectedClientId); - return new ObjectResult(new { error = "unexpected_caller" }) { StatusCode = 403 }; - } + return Reply(403, new { error = "unexpected_caller" }); var auth = await _tokens.ValidateAsync(req.Headers.Authorization.FirstOrDefault()); if (!auth.Ok) - { - _log.LogError("{Tag} token rejected: {Reason}", Tag, auth.Reason); - return new ObjectResult(new { error = "unauthorized", reason = auth.Reason, requestId }) { StatusCode = 401 }; - } + return Reply(401, new { error = "unauthorized", requestId }); JsonElement payload; try @@ -103,110 +93,74 @@ public async Task Run( } catch { - _log.LogError("{Tag} body is not JSON", Tag); - return new BadRequestObjectResult(new { error = "bad_request", reason = "invalid JSON body", requestId }); + return Reply(400, new { error = "bad_request", reason = "invalid JSON body", requestId }); } var (envelope, envelopeError) = EnvelopeParser.Parse(payload); if (envelopeError is not null) - { - _log.LogError("{Tag} envelope rejected: {Reason}", Tag, envelopeError); - return new BadRequestObjectResult(new { error = "bad_request", reason = envelopeError, requestId }); - } - - Log("type", envelope!.Type); - Log("tenantId", envelope.TenantId); - Log("correlationId", envelope.CorrelationId); - Log("channel", envelope.Channel); - Log("mode", envelope.Mode); - Log("ttlSeconds", envelope.TtlSeconds); - - correlationId = envelope.CorrelationId ?? headerCorrelationId ?? requestId; + return Reply(400, new { error = "bad_request", reason = envelopeError, requestId }); - // Surfaced rather than swallowed: the passcode expires before it can be used. - if (envelope.TtlSeconds is <= 0) - _log.LogWarning("{Tag} ttlSeconds is {Ttl}; the passcode has expired.", Tag, envelope.TtlSeconds); + correlationId = envelope!.CorrelationId ?? correlationId; + evaluation = envelope.Mode == EnvelopeParser.ModeEvaluation; + var channel = EnvelopeParser.ChannelName(envelope.Channel)!; + logChannel = DispatchEngine.SafeChannel(channel); + mode = envelope.Mode switch + { + EnvelopeParser.ModeLive => "live", + EnvelopeParser.ModeEvaluation => "evaluation", + _ => "unknown", + }; JweResult decrypted; try { decrypted = _decryptor.Decrypt(envelope.EncryptedDeliveryContext); } - catch (Exception ex) + catch { - _log.LogError("{Tag} decryption failed: {Reason}", Tag, ex.Message); - return new ObjectResult(new { error = "decryption_failed", correlationId, requestId }) { StatusCode = 400 }; + return Reply(400, new { error = "decryption_failed", correlationId, requestId }); } - var kidMatches = string.IsNullOrEmpty(expectedKeyId) || decrypted.Kid == expectedKeyId; - Log("kid", $"{decrypted.Kid}{(kidMatches ? "" : " (DOES NOT match EPP_ENCRYPTION_KEY_ID)")}"); - Log("alg / enc", $"{decrypted.Alg} / {decrypted.Enc}"); - Log("decrypted", "OK"); - var context = decrypted.Context; - Log("nonce", context.Nonce); - - if (logPlaintext) - { - // DIAGNOSTICS ONLY — writes the phone number and passcode to the log. - Log("phoneNumber", context.PhoneNumber); - Log("extension", context.Extension ?? "(none)"); - Log("locale", context.Locale); - Log("message", context.Message); - Log("riskContext", context.RiskContext.HasValue ? context.RiskContext.Value.ToString() : "(none)"); - } - else - { - _log.LogInformation("{Tag} plaintext suppressed (EPP_LOG_PLAINTEXT=false)", Tag); - } if (string.IsNullOrEmpty(context.Nonce) || string.IsNullOrEmpty(context.PhoneNumber) || string.IsNullOrEmpty(context.Message)) - { - _log.LogError("{Tag} delivery context is incomplete (nonce/phoneNumber/message)", Tag); - return new ObjectResult(new { error = "bad_request", reason = "incomplete delivery context", correlationId, requestId }) { StatusCode = 400 }; - } - - var evaluation = envelope.Mode == EnvelopeParser.ModeEvaluation; - var channel = EnvelopeParser.ChannelName(envelope.Channel)!; + return Reply(400, new { error = "bad_request", reason = "incomplete delivery context", correlationId, requestId }); var dispatch = new DispatchRequest( Destination: context.PhoneNumber!, - Message: channel == "voice" ? SpacePasscodeForVoice(context.Message) : context.Message, + Message: context.Message, Channel: channel, MessageId: clientRequestId, CorrelationId: correlationId, Locale: context.Locale); - // Microsoft allows 3.2 s for the whole call, so the provider is called after the response. - var deliveryCorrelationId = correlationId; - _ = Task.Run(async () => + var providerResult = await _engine.DispatchAsync(dispatch, null, evaluation, requestId, _log); + outcome = providerResult.HttpStatus switch { - try - { - var result = await _engine.DispatchAsync(dispatch, null, evaluation, requestId, _log); - _log.LogInformation("{Tag} provider result : httpStatus={Status} correlationId={CorrelationId}", - Tag, result.HttpStatus, deliveryCorrelationId); - } - catch (Exception ex) - { - _log.LogError("{Tag} provider delivery failed: {Error}", Tag, ex.Message); - } - }); - - // Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery - // and Microsoft re-sends over its own telephony, so the user gets the code twice. - Log("responding", $"200, nonce echoed, {(DateTimeOffset.UtcNow - started).TotalMilliseconds:F0} ms"); - _log.LogInformation("{Tag} ======== done ========", Tag); - - return new ObjectResult(new { nonce = context.Nonce, correlationId, providerStatus = "accepted" }) - { StatusCode = 200 }; + 200 => Outcome.Continue, + 403 => Outcome.Block, + 409 => Outcome.StepUp, + _ => Outcome.Fail, + }; + + if (providerResult.HttpStatus != 200) + return Reply(providerResult.HttpStatus, new { error = "delivery_failed", reason = "provider delivery failed", correlationId, requestId }); + + // A 2xx without the nonce triggers SAS fallback and risks duplicate delivery. + nonceEcho = true; + shutterProcessed = evaluation; + return Reply(200, new { nonce = context.Nonce, correlationId, providerStatus = "accepted" }); + } + catch + { + outcome = Outcome.Fail; + return Reply(500, new { error = "delivery_failed", correlationId, requestId }); } - catch (Exception ex) + finally { - // Verbose on purpose: this endpoint exists to diagnose onboarding. - _log.LogError("{Tag} FAILED after {Elapsed} ms: {Error}", Tag, (DateTimeOffset.UtcNow - started).TotalMilliseconds, ex.Message); - _log.LogInformation("{Tag} ======== failed ========", Tag); - return new ObjectResult(new { error = "delivery_failed", detail = ex.Message, correlationId }) { StatusCode = 500 }; + _log.LogInformation("[EPP_RESULT] requestId={RequestId} correlationId={CorrelationId} provider={Provider} channel={Channel} mode={Mode} httpStatus={HttpStatus} outcome={Outcome} nonceEcho={NonceEcho} shutterProcessed={ShutterProcessed} elapsedMs={ElapsedMs}", + DispatchEngine.SafeTraceId(requestId), DispatchEngine.SafeTraceId(correlationId), provider, logChannel, mode, + status, outcome, nonceEcho, shutterProcessed, started.ElapsedMilliseconds); } } } diff --git a/dotnet/Program.cs b/dotnet/Program.cs index f9ae1d2..a817cc2 100644 --- a/dotnet/Program.cs +++ b/dotnet/Program.cs @@ -12,6 +12,7 @@ builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index ea1d6a5..fd9333e 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -6,9 +6,6 @@ namespace Epp.Otp; -// Delivery pipeline: parse the cleartext SAS envelope, decrypt the JWE that carries the PII, then -// dispatch to the configured provider. Fail-closed — only a Continue outcome is "accepted". - public sealed record Envelope( string? Type, string? TenantId, @@ -22,6 +19,7 @@ public static class EnvelopeParser { public const int ModeLive = 1; public const int ModeEvaluation = 2; + public const string EnvelopeType = "microsoft.mfa.otpDeliver.v1"; private static readonly Dictionary ChannelByCode = new() { [1] = "sms", [2] = "voice" }; private static readonly Dictionary ChannelByName = new(StringComparer.OrdinalIgnoreCase) { ["sms"] = 1, ["voice"] = 2 }; @@ -54,6 +52,10 @@ public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) return name is not null && ModeByName.TryGetValue(name, out var mapped) ? mapped : null; } + var type = String("type"); + if (type != EnvelopeType) + return (null, "unsupported type"); + var encrypted = String("encryptedDeliveryContext"); if (string.IsNullOrEmpty(encrypted)) return (null, "encryptedDeliveryContext is required"); @@ -66,8 +68,19 @@ public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) if (mode is null) return (null, "unsupported mode"); - return (new Envelope(String("type"), String("tenantId"), String("correlationId"), - channel.Value, mode.Value, Int("ttlSeconds"), encrypted), null); + int? ttlSeconds = null; + if (payload.TryGetProperty("ttlSeconds", out var ttlElement)) + { + if (ttlElement.ValueKind != JsonValueKind.Number + || !ttlElement.TryGetInt32(out var ttlValue)) + return (null, "ttlSeconds must be a positive integer"); + if (ttlValue <= 0) + return (null, "passcode has expired"); + ttlSeconds = ttlValue; + } + + return (new Envelope(type, String("tenantId"), String("correlationId"), + channel.Value, mode.Value, ttlSeconds, encrypted), null); } } @@ -84,7 +97,6 @@ public sealed class DeliveryContext public sealed record JweResult(string? Kid, string? Alg, string? Enc, DeliveryContext Context); -// Injectable so tests use a local key. public interface IJweKeyProvider { RSA GetPrivateKey(string? kid); @@ -105,7 +117,7 @@ public JweResult Decrypt(string compactJwe) var alg = headers.TryGetValue("alg", out var algValue) ? algValue?.ToString() : null; var enc = headers.TryGetValue("enc", out var encValue) ? encValue?.ToString() : null; var rsa = _keys.GetPrivateKey(kid); - // Pin alg/enc so a tampered header can't downgrade the crypto. + // Pin alg/enc so a tampered header cannot downgrade the encryption. var plaintext = Jose.JWT.Decrypt(compactJwe, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); var context = JsonSerializer.Deserialize(plaintext) ?? new DeliveryContext(); return new JweResult(kid, alg, enc, context); @@ -124,7 +136,7 @@ private static void AssertWellFormed(string compactJwe) } } -// Imported once: a per-delivery RSA import would sit inside the response budget. +// Cache the imported key: re-importing RSA on every delivery would eat the response budget. public sealed class EnvJweKeyProvider : IJweKeyProvider { private readonly IEnv _env; @@ -148,8 +160,8 @@ public RSA GetPrivateKey(string? kid) return rsa; } - // The setup script stores the key as base64 over the PEM so its newlines survive being carried as - // an app setting, so accept either form. + // The key may arrive as a PEM or as base64 over the PEM (the setup script uses base64 so newlines + // survive being stored as an app setting); accept either form. private static string NormalizePem(string value) => value.Contains("-----BEGIN", StringComparison.Ordinal) ? value @@ -159,39 +171,54 @@ private static string NormalizePem(string value) => public sealed class DispatchEngine { private const int DefaultTimeoutMs = 1500; + private const int MaxProviderTimeoutMs = 2500; private readonly ProviderRegistry _registry; private readonly ISecretResolver _secrets; private readonly IHttpClientFactory _httpFactory; private readonly IEnv _env; + private readonly IProviderTokenAcquirer _tokenAcquirer; - public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null) + public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null, IProviderTokenAcquirer? tokenAcquirer = null) { _registry = registry; _secrets = secrets; _httpFactory = httpFactory; _env = env ?? new ProcessEnv(); + _tokenAcquirer = tokenAcquirer ?? new ProviderTokenAcquirer(_env, _secrets); } public async Task DispatchAsync(DispatchRequest dispatch, string? requestProvider, bool shutter, string requestId, ILogger log) { - var adapter = _registry.Resolve(requestProvider); - if (adapter is null) + var traceRequestId = SafeTraceId(requestId); + var traceCorrelationId = SafeTraceId(dispatch.CorrelationId); + var traceProvider = SafeProvider(requestProvider); + var traceChannel = SafeChannel(dispatch.Channel ?? "sms"); + + DispatchResult Complete(int status, object body, Outcome outcome = Outcome.Fail) { - log.LogWarning("[DISPATCH_ERROR] requestId={RequestId} unknown provider={Provider}", requestId, requestProvider ?? "n/a"); - return new DispatchResult(400, new { status = "error", reason = "unknown provider", requestId }); + log.LogInformation("[DISPATCH_RESULT] requestId={RequestId} correlationId={CorrelationId} provider={Provider} channel={Channel} outcome={Outcome} httpStatus={HttpStatus} shutterProcessed={ShutterProcessed}", + traceRequestId, traceCorrelationId, traceProvider, traceChannel, outcome, status, shutter && status == 200); + return new DispatchResult(status, body); } + var adapter = _registry.Resolve(requestProvider); + if (adapter is null) + return Complete(400, new { status = "error", reason = "unknown provider", requestId }); + var manifest = adapter.Manifest; var providerId = manifest.Id; + traceProvider = SafeProvider(providerId); var channel = (dispatch.Channel ?? "sms").ToLowerInvariant(); if (!OutcomeMapper.DefaultChannels.Contains(channel)) - return new DispatchResult(400, new { status = "error", provider = providerId, reason = $"channel '{channel}' not supported", requestId }); + return Complete(400, new { status = "error", provider = providerId, reason = "unsupported channel", requestId }); + + if (shutter) + return Complete(200, new { status = "accepted", shutterProcessed = true, provider = providerId, channel, correlationId = dispatch.CorrelationId, messageId = dispatch.MessageId, requestId }, Outcome.Continue); - // Credential (fail closed 502 if missing) — this is our credential, not the caller's token. ProviderCredential? credential = null; try { credential = await ResolveCredentialAsync(manifest.Auth); } - catch (Exception ex) { log.LogError("[DISPATCH_ERROR] requestId={RequestId} provider={Provider} credential error={Error}", requestId, providerId, ex.Message); } + catch { /* Credential failures are reported below without SDK exception details. */ } var identityRequired = credential is { Mode: "apiKey" } && !string.IsNullOrEmpty(manifest.Auth.IdentityKeyVaultSecretName); var credentialUnavailable = credential is null @@ -199,75 +226,106 @@ public async Task DispatchAsync(DispatchRequest dispatch, string || (credential.Mode == "apiKey" && string.IsNullOrEmpty(credential.Secret)) || (identityRequired && string.IsNullOrEmpty(credential.Identity)); if (credentialUnavailable) - return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); + return Complete(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); - var endpoint = ResolveEndpoint(manifest, _env); - if (string.IsNullOrEmpty(endpoint)) - return new DispatchResult(502, FailBody(providerId, channel, "provider endpoint not configured", dispatch, requestId)); + var endpoint = _env.Get("EPP_PROVIDER_ENDPOINT"); + if (!IsValidProviderEndpoint(endpoint)) + return Complete(502, FailBody(providerId, channel, "provider endpoint must be an absolute HTTPS URL", dispatch, requestId)); - var req = adapter.BuildRequest(channel, endpoint, dispatch, credential!, _env); - log.LogInformation("[DISPATCH] requestId={RequestId} provider={Provider} channel={Channel} shutter={Shutter}", requestId, providerId, channel, shutter); + var req = adapter.BuildRequest(channel, endpoint!, dispatch, credential!, _env); + if (!IsValidProviderEndpoint(req.Url)) + return Complete(502, FailBody(providerId, channel, "provider request URL must be absolute HTTPS", dispatch, requestId)); - if (shutter) - return new DispatchResult(200, new { status = "accepted", shutterProcessed = true, provider = providerId, channel, correlationId = dispatch.CorrelationId, messageId = dispatch.MessageId, requestId }); - - var timeoutMs = int.TryParse(_env.Get("EPP_PROVIDER_TIMEOUT_MS"), out var parsedTimeout) ? parsedTimeout : DefaultTimeoutMs; - HttpResponseMessage resp; + var timeoutMs = NormalizeProviderTimeoutMs(_env.Get("EPP_PROVIDER_TIMEOUT_MS")); + int providerStatusCode; + bool providerRequestSucceeded; string body; try { - (resp, body) = await SendAsync(req, timeoutMs); + (providerStatusCode, providerRequestSucceeded, body) = await SendAsync(req, timeoutMs); } catch (OperationCanceledException) { - log.LogWarning("[DISPATCH_TIMEOUT] requestId={RequestId} provider={Provider}", requestId, providerId); - return new DispatchResult(504, FailBody(providerId, channel, $"endpoint timeout after {timeoutMs}ms", dispatch, requestId)); + return Complete(504, FailBody(providerId, channel, $"endpoint timeout after {timeoutMs}ms", dispatch, requestId)); } - catch (Exception ex) + catch { - log.LogError("[DISPATCH_ERROR] requestId={RequestId} provider={Provider} reason={Reason}", requestId, providerId, ex.Message); - return new DispatchResult(502, FailBody(providerId, channel, ex.Message, dispatch, requestId)); + return Complete(502, FailBody(providerId, channel, "provider request failed", dispatch, requestId)); } JsonElement json; try { using var responseDocument = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); json = responseDocument.RootElement.Clone(); } catch { using var emptyDocument = JsonDocument.Parse("{}"); json = emptyDocument.RootElement.Clone(); } - var parsed = adapter.ParseResponse((int)resp.StatusCode, resp.IsSuccessStatusCode, json); + var parsed = adapter.ParseResponse(providerStatusCode, providerRequestSucceeded, json); var outcome = OutcomeMapper.ResolveOutcome(manifest, parsed); var httpStatus = OutcomeMapper.ToHttpStatus(outcome, parsed.ProviderHttpStatus); - log.LogInformation("[DISPATCH_RESULT] requestId={RequestId} provider={Provider} channel={Channel} outcome={Outcome} providerStatus={Status} httpStatus={Http}", - requestId, providerId, channel, outcome, parsed.ProviderStatusName ?? parsed.ProviderStatusCode ?? "n/a", httpStatus); - - return new DispatchResult(httpStatus, new + // Provider diagnostics may echo delivery secrets, even on a success-looking response. + return Complete(httpStatus, new { status = outcome == Outcome.Continue ? "accepted" : "failed", outcome = outcome.ToString(), + reason = outcome == Outcome.Continue ? null : "provider delivery failed", provider = providerId, channel, messageId = dispatch.MessageId, correlationId = dispatch.CorrelationId, - providerMessageId = parsed.ProviderMessageId, - providerStatus = parsed.ProviderStatusName ?? parsed.ProviderStatusCode, - providerStatusDescription = parsed.ProviderStatusDescription, requestId, - }); + }, outcome); + } + + // Log projection only: never replace IDs on the dispatch or response wire. + internal static string SafeTraceId(string? value) + { + if (string.IsNullOrEmpty(value)) return "unknown"; + if (Guid.TryParse(value, out var id)) return id.ToString("D"); + // A labeled 96-bit SHA256 prefix preserves correlation without logging arbitrary text. + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return "sha256:" + Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant(); } + internal static string SafeProvider(string? value) => value?.ToLowerInvariant() switch + { + "infobip" => "infobip", + "telesign" => "telesign", + "sinch" => "sinch", + "soprano" => "soprano", + _ => "unknown", + }; + + internal static string SafeChannel(string? value) => value?.ToLowerInvariant() switch + { + "sms" => "sms", + "voice" => "voice", + _ => "unknown", + }; + private async Task ResolveCredentialAsync(AuthConfig auth) { - if (auth.Mode == "oauth2") return new ProviderCredential("oauth2", Token: null); // not wired -> fails closed + var mode = _env.Get("EPP_PROVIDER_AUTH_MODE"); + if (string.IsNullOrEmpty(mode)) mode = auth.Mode; + if (string.Equals(mode, "oauth2", StringComparison.OrdinalIgnoreCase)) + { + var token = await _tokenAcquirer.AcquireAsync(); + return new ProviderCredential("oauth2", Token: token); + } var secret = await _secrets.ResolveAsync(auth.KeyVaultSecretName); var identity = string.IsNullOrEmpty(auth.IdentityKeyVaultSecretName) ? string.Empty : await _secrets.ResolveAsync(auth.IdentityKeyVaultSecretName); return new ProviderCredential("apiKey", Secret: secret, Identity: identity); } - // Base URL from app settings: one provider is active per deployment, so the endpoint is a single - // EPP_PROVIDER_ENDPOINT rather than a per-provider key. - private static string? ResolveEndpoint(ProviderManifest manifest, IEnv env) => env.Get("EPP_PROVIDER_ENDPOINT"); + private static bool IsValidProviderEndpoint(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttps + && !string.IsNullOrEmpty(uri.Host); + + internal static int NormalizeProviderTimeoutMs(string? value) => + int.TryParse(value, out var parsed) && parsed > 0 + ? Math.Min(parsed, MaxProviderTimeoutMs) + : DefaultTimeoutMs; - private async Task<(HttpResponseMessage, string)> SendAsync(ProviderHttpRequest req, int timeoutMs) + private async Task<(int StatusCode, bool IsSuccessStatusCode, string Body)> SendAsync(ProviderHttpRequest req, int timeoutMs) { using var cts = new CancellationTokenSource(timeoutMs); var client = _httpFactory.CreateClient(); @@ -280,9 +338,10 @@ private async Task ResolveCredentialAsync(AuthConfig auth) if (k.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) continue; if (!message.Headers.TryAddWithoutValidation(k, v)) message.Content.Headers.TryAddWithoutValidation(k, v); } - var resp = await client.SendAsync(message, cts.Token); + // Do not retry an OTP delivery: SAS/the provider own resends and duplicate suppression. + using var resp = await client.SendAsync(message, cts.Token); var body = await resp.Content.ReadAsStringAsync(cts.Token); - return (resp, body); + return ((int)resp.StatusCode, resp.IsSuccessStatusCode, body); } private static object FailBody(string provider, string channel, string reason, DispatchRequest d, string requestId) => diff --git a/dotnet/Src/ISecretResolver.cs b/dotnet/Src/ISecretResolver.cs index eab5801..67dcfae 100644 --- a/dotnet/Src/ISecretResolver.cs +++ b/dotnet/Src/ISecretResolver.cs @@ -1,6 +1,6 @@ namespace Epp.Otp; -// Seam over Key Vault so the engine can be unit-tested with a fake. +// Abstraction over Key Vault secret retrieval. public interface ISecretResolver { Task ResolveAsync(string? secretName); diff --git a/dotnet/Src/OutcomeMapper.cs b/dotnet/Src/OutcomeMapper.cs index 174501d..e084233 100644 --- a/dotnet/Src/OutcomeMapper.cs +++ b/dotnet/Src/OutcomeMapper.cs @@ -10,7 +10,8 @@ public static Outcome ResolveOutcome(ProviderManifest manifest, ParsedResponse p var key = parsed.ProviderStatusName ?? parsed.ProviderStatusCode; if (!string.IsNullOrEmpty(key)) { - if (manifest.ResponseMapping.TryGetValue(key, out var mapped)) return mapped; + if (manifest.ResponseMapping.TryGetValue(key, out var mapped)) + return mapped == Outcome.Continue && !parsed.Success ? Outcome.Fail : mapped; return manifest.ResponseMapping.TryGetValue("default", out var defaultOutcome) ? defaultOutcome : Outcome.Fail; } if (parsed.Success) return Outcome.Continue; diff --git a/dotnet/Src/ProviderRegistry.cs b/dotnet/Src/ProviderRegistry.cs index f6fc314..5edf792 100644 --- a/dotnet/Src/ProviderRegistry.cs +++ b/dotnet/Src/ProviderRegistry.cs @@ -1,6 +1,6 @@ namespace Epp.Otp; -// One provider is active per deployment; requestProvider is a test override. +// One provider is active per deployment; requestProvider overrides EPP_PROVIDER_NAME when set. public sealed class ProviderRegistry { private readonly IReadOnlyDictionary _byId; diff --git a/dotnet/Src/ProviderTokenAcquirer.cs b/dotnet/Src/ProviderTokenAcquirer.cs new file mode 100644 index 0000000..a8ad9e5 --- /dev/null +++ b/dotnet/Src/ProviderTokenAcquirer.cs @@ -0,0 +1,73 @@ +using Azure.Core; +using Azure.Identity; + +namespace Epp.Otp; + +// Provider auth in oauth2 mode: mint our own app-only Entra JWT (client-credentials) for the +// provider's API and send it as a Bearer token, never the caller's inbound token. Issuer is our app, +// audience is the provider's app (the scope). Injectable so tests do not reach Entra. +public interface IProviderTokenAcquirer +{ + Task AcquireAsync(CancellationToken cancellationToken = default); +} + +public sealed class ProviderTokenAcquirer : IProviderTokenAcquirer +{ + private static readonly TimeSpan ExpirySkew = TimeSpan.FromMinutes(5); + private readonly IEnv _env; + private readonly ISecretResolver _secrets; + private (string Token, DateTimeOffset Expires, string Key)? _cache; + + public ProviderTokenAcquirer(IEnv env, ISecretResolver secrets) + { + _env = env; + _secrets = secrets; + } + + public async Task AcquireAsync(CancellationToken cancellationToken = default) + { + var tenantId = _env.Get("EPP_PROVIDER_TENANT_ID"); + var clientId = _env.Get("EPP_PROVIDER_CLIENT_ID"); + var scope = _env.Get("EPP_PROVIDER_SCOPE"); + if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(scope)) + throw new InvalidOperationException("oauth2 requires EPP_PROVIDER_TENANT_ID, EPP_PROVIDER_CLIENT_ID and EPP_PROVIDER_SCOPE"); + + var cacheKey = $"{tenantId}|{clientId}|{scope}"; + if (_cache is { } cached && cached.Key == cacheKey && cached.Expires - ExpirySkew > DateTimeOffset.UtcNow) + return cached.Token; + + var credential = await BuildCredentialAsync(tenantId, clientId); + var token = await credential.GetTokenAsync(new TokenRequestContext(new[] { scope }), cancellationToken); + _cache = (token.Token, token.ExpiresOn, cacheKey); + return token.Token; + } + + // Managed-identity federation (selected by EPP_PROVIDER_MI_CLIENT_ID) keeps the cross-tenant call + // secretless; otherwise use a client secret from Key Vault (or an env var for local runs). + private async Task BuildCredentialAsync(string tenantId, string clientId) + { + var managedIdentityClientId = _env.Get("EPP_PROVIDER_MI_CLIENT_ID"); + if (!string.IsNullOrEmpty(managedIdentityClientId)) + { + var managedIdentity = new ManagedIdentityCredential(managedIdentityClientId); + var audience = _env.Get("EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE") ?? "api://AzureADTokenExchange"; + var exchangeScope = audience.EndsWith("/.default", StringComparison.Ordinal) ? audience : $"{audience}/.default"; + return new ClientAssertionCredential(tenantId, clientId, async ct => + { + var assertion = await managedIdentity.GetTokenAsync( + new TokenRequestContext(new[] { exchangeScope }), ct); + return assertion.Token; + }); + } + + var secret = _env.Get("EPP_PROVIDER_CLIENT_SECRET"); + if (string.IsNullOrEmpty(secret)) + { + var secretName = _env.Get("EPP_PROVIDER_CLIENT_SECRET_NAME"); + secret = string.IsNullOrEmpty(secretName) ? string.Empty : await _secrets.ResolveAsync(secretName); + } + if (string.IsNullOrEmpty(secret)) + throw new InvalidOperationException("oauth2 requires EPP_PROVIDER_MI_CLIENT_ID (managed identity) or EPP_PROVIDER_CLIENT_SECRET_NAME"); + return new ClientSecretCredential(tenantId, clientId, secret); + } +} diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index 537789e..bb902ad 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -2,7 +2,9 @@ namespace Epp.Otp.Providers; -// Soprano Connect (MEMS): POST {base}/messages/{sms|voice}. Auth: X-MEMS-API-ID + X-MEMS-API-Key. +// Soprano Connect (MEMS): POST {base}/messages/omnimsg. One endpoint for every channel — +// `messageTypes` picks it and Soprano does the TTS for voice. +// Auth: an Entra ID v2.0 Bearer JWT (audience = Soprano's app id), or X-MEMS-API-ID + X-MEMS-API-Key. public sealed class SopranoProvider : IProviderAdapter { public ProviderManifest Manifest { get; } = new( @@ -16,6 +18,8 @@ public sealed class SopranoProvider : IProviderAdapter ["SENT"] = Outcome.Continue, ["DELIVERED"] = Outcome.Continue, ["QUEUED"] = Outcome.Continue, + // Accepted (HTTP 201) but stopped by an account/destination filter — nothing was delivered. + ["FILTERED"] = Outcome.Fail, ["FAILED"] = Outcome.Fail, ["REJECTED"] = Outcome.Fail, ["BLOCKED"] = Outcome.Block, @@ -24,51 +28,21 @@ public sealed class SopranoProvider : IProviderAdapter public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) { - var messageType = channel == "voice" ? "voice" : "sms"; var headers = new Dictionary { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }; if (credential.Mode == "oauth2") headers["Authorization"] = $"Bearer {credential.Token}"; else { headers["X-MEMS-API-ID"] = credential.Identity ?? string.Empty; headers["X-MEMS-API-Key"] = credential.Secret ?? string.Empty; } - // Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A - // non-numeric account name is sent as a free-text source instead. - object endpoints_or_source() + var body = new { - var account = env.Get("EPP_PROVIDER_ACCOUNT_NAME"); - if (!string.IsNullOrEmpty(account) && int.TryParse(account, out var sourceId)) - return new { endpoints = new[] { new { type = int.TryParse(env.Get("SOPRANO_SOURCE_TYPE"), out var parsedSourceType) ? parsedSourceType : 1, id = sourceId } } }; - return new { source = account }; - } + text = dispatch.Message, + destination = (dispatch.Destination ?? string.Empty).TrimStart('+'), // E.164 without the leading + + messageTypes = new[] { channel == "voice" ? "voice" : "sms" }, + correlationId = dispatch.CorrelationId ?? dispatch.MessageId, + // Soprano processes the request but delivers nothing — connectivity/credential testing. + shutterMode = string.Equals(env.Get("SOPRANO_SHUTTER_MODE"), "true", StringComparison.OrdinalIgnoreCase), + }; - var clientRef = dispatch.CorrelationId ?? dispatch.MessageId; - object body; - if (messageType == "voice") - { - var voiceLanguage = env.Get("SOPRANO_VOICE_LANGUAGE") ?? ((dispatch.Locale?.Contains('-') ?? false) ? dispatch.Locale! : "en-US"); - body = Merge(endpoints_or_source(), new - { - messageType, - destination = dispatch.Destination, - clientReference = clientRef, - voice = new - { - text2voice = new - { - beforePasswordText = dispatch.Message ?? string.Empty, - password = string.Empty, - afterPasswordText = string.Empty, - language = voiceLanguage, - gender = int.TryParse(env.Get("SOPRANO_VOICE_GENDER"), out var parsedGender) ? parsedGender : 1, - loop = 1, - }, - }, - }); - } - else - { - body = Merge(endpoints_or_source(), new { messageType, destination = dispatch.Destination, text = dispatch.Message, clientReference = clientRef }); - } - - return new ProviderHttpRequest($"{endpoint}/messages/{messageType}", "POST", headers, JsonSerializer.Serialize(body)); + return new ProviderHttpRequest($"{endpoint}/messages/omnimsg", "POST", headers, JsonSerializer.Serialize(body)); } public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) @@ -88,13 +62,4 @@ public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) status ??= ok ? "SUBMITTED" : null; return new ParsedResponse(ok, httpStatus, id, status, null, desc); } - - // Shallow-merge two anonymous objects into a dictionary for JSON serialization. - private static Dictionary Merge(object first, object second) - { - var merged = new Dictionary(); - foreach (var property in first.GetType().GetProperties()) merged[property.Name] = property.GetValue(first); - foreach (var property in second.GetType().GetProperties()) merged[property.Name] = property.GetValue(second); - return merged; - } } diff --git a/dotnet/Src/TokenValidator.cs b/dotnet/Src/TokenValidator.cs index a8f43c5..7da13dc 100644 --- a/dotnet/Src/TokenValidator.cs +++ b/dotnet/Src/TokenValidator.cs @@ -6,7 +6,7 @@ namespace Epp.Otp; // Validates the Entra JWT when EPP_REQUIRE_AUTH=true (aud/issuer/JWKS, RS256). No-op pass-through -// otherwise — Easy Auth is the primary gate; this is the backstop. +// otherwise. Easy Auth is the primary gate; this is the backstop. public sealed class TokenValidator { private readonly JwtSecurityTokenHandler _handler = new(); @@ -24,7 +24,12 @@ public sealed record Result(bool Ok, string? Reason = null, string? CallerObject public async Task ValidateAsync(string? authorizationHeader) { - if (!string.Equals(_env.Get("EPP_REQUIRE_AUTH"), "true", StringComparison.OrdinalIgnoreCase)) + var requireAuth = string.Equals(_env.Get("EPP_REQUIRE_AUTH"), "true", StringComparison.OrdinalIgnoreCase); + var runningInAzure = !string.IsNullOrEmpty(_env.Get("WEBSITE_INSTANCE_ID")) + || !string.IsNullOrEmpty(_env.Get("WEBSITE_HOSTNAME")); + if (!requireAuth && runningInAzure) + return new Result(false, "EPP_REQUIRE_AUTH must be true in Azure"); + if (!requireAuth) return new Result(true); var audience = _env.Get("EPP_EXPECTED_AUDIENCE"); diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 8447a47..4f4a5db 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using Epp.Otp; using Epp.Otp.Providers; @@ -5,7 +6,6 @@ namespace Epp.Otp.Tests; -// Conformance tests for the pure contract logic (see /docs/CONTRACT.md §6). public class ContractTests { private sealed class FakeEnv : Dictionary, IEnv @@ -16,51 +16,37 @@ private sealed class FakeEnv : Dictionary, IEnv private static DispatchRequest Disp(string channel = "sms", string? message = null) => new("+15551234567", message, channel, "m", "c", null); - // Easy Auth normally rejects the wrong caller at the platform; these cover the standalone path. - [Theory] - [InlineData("anything", "", true)] // unpinned client id accepts any caller - [InlineData("expected-app", "expected-app", true)] - [InlineData("EXPECTED-APP", "expected-app", true)] // Entra ids are case-insensitive - [InlineData("some-other-app", "expected-app", false)] - [InlineData(null, "expected-app", false)] // token carrying no caller claim - public void CallerIsCheckedAgainstExpectedClientId(string? callerAppId, string expected, bool allowed) => - Assert.Equal(allowed, TokenValidator.IsExpectedCaller(callerAppId, expected)); - [Fact] - public void TokenValidationIsSkippedUnlessRequireAuthIsTrue() + public void ExpectedCallerIsCaseInsensitiveButRequired() { - var env = new FakeEnv { ["EPP_REQUIRE_AUTH"] = "false" }; - Assert.True(new TokenValidator(env).ValidateAsync("Bearer whatever").Result.Ok); + Assert.True(TokenValidator.IsExpectedCaller("EXPECTED-APP", "expected-app")); + Assert.False(TokenValidator.IsExpectedCaller(null, "expected-app")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AuthCanBeDisabledLocallyButFailsClosedInAzure(bool onAzure) + { + var env = new FakeEnv + { + ["EPP_REQUIRE_AUTH"] = "false", + ["WEBSITE_INSTANCE_ID"] = onAzure ? "instance" : null, + }; + var result = await new TokenValidator(env).ValidateAsync(null); + Assert.Equal(!onAzure, result.Ok); + if (onAzure) Assert.Contains("EPP_REQUIRE_AUTH", result.Reason); } [Fact] - public void OutcomeMappingAndHttpStatus() + public void BlockStepUpAndClientErrorsKeepTheirMappings() { - var m = new InfobipProvider().Manifest; - Assert.Equal(Outcome.Continue, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusName: "DELIVERED"))); - // Unknown status fails closed even on HTTP 200. - Assert.Equal(Outcome.Fail, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusName: "WATWAT"))); - Assert.Equal(200, OutcomeMapper.ToHttpStatus(Outcome.Continue, 200)); + Assert.Equal(Outcome.Block, OutcomeMapper.ResolveOutcome(new SopranoProvider().Manifest, + new ParsedResponse(false, 403, ProviderStatusName: "BLOCKED"))); Assert.Equal(403, OutcomeMapper.ToHttpStatus(Outcome.Block, 200)); Assert.Equal(409, OutcomeMapper.ToHttpStatus(Outcome.StepUp, 200)); - Assert.Equal(429, OutcomeMapper.ToHttpStatus(Outcome.Fail, 429)); Assert.Equal(401, OutcomeMapper.ToHttpStatus(Outcome.Fail, 403)); Assert.Equal(400, OutcomeMapper.ToHttpStatus(Outcome.Fail, 422)); - Assert.Equal(502, OutcomeMapper.ToHttpStatus(Outcome.Fail, 500)); - } - - [Fact] - public void InfobipBuildsHttpsSmsRequestWithAppAuthAndCode() - { - var env = new FakeEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "EPP" }; - var req = new InfobipProvider().BuildRequest("sms", "https://api.infobip.com", - Disp(message: "Use verification code 918273 for Microsoft authentication."), - new ProviderCredential("apiKey", Secret: "ib"), env); - - Assert.StartsWith("https://", req.Url); - Assert.EndsWith("/sms/3/messages", req.Url); - Assert.StartsWith("App ", req.Headers["Authorization"]); - Assert.Contains("918273", req.Body); } [Fact] @@ -69,21 +55,37 @@ public void TelesignUsesBasicAuthAndVoiceMapping() var env = new FakeEnv(); var req = new TelesignProvider().BuildRequest("sms", "https://rest-api.telesign.com", Disp(message: "code 918273"), new ProviderCredential("apiKey", Secret: "key", Identity: "cust"), env); - Assert.StartsWith("Basic ", req.Headers["Authorization"]); + Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("cust:key")), req.Headers["Authorization"]); Assert.EndsWith("/v1/messaging", req.Url); var m = new TelesignProvider().Manifest; Assert.Equal(Outcome.Continue, OutcomeMapper.ResolveOutcome(m, new ParsedResponse(true, 200, ProviderStatusCode: "100"))); } + [Theory] + [InlineData("sms")] + [InlineData("voice")] + public void SopranoPostsTheOmnimsgPayload(string channel) + { + var req = new SopranoProvider().BuildRequest(channel, "https://qa.example.com/cgpapi", + Disp(channel, "code 918273"), new ProviderCredential("apiKey", Secret: "k", Identity: "id"), new FakeEnv()); + + Assert.Equal("id", req.Headers["X-MEMS-API-ID"]); + Assert.Equal("k", req.Headers["X-MEMS-API-Key"]); + Assert.EndsWith("/messages/omnimsg", req.Url); + using var body = JsonDocument.Parse(req.Body); + Assert.Equal(channel, body.RootElement.GetProperty("messageTypes")[0].GetString()); + Assert.Equal("15551234567", body.RootElement.GetProperty("destination").GetString()); + Assert.Contains("918273", body.RootElement.GetProperty("text").GetString()); + } + [Fact] - public void ProviderRegistryResolvesById() + public void SinchUsesBearerAuthForSms() { - var reg = new ProviderRegistry(new IProviderAdapter[] - { - new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider(), - }); - Assert.Equal("telesign", reg.Get("TELESIGN")!.Manifest.Id); - Assert.Null(reg.Get("nope")); + var env = new FakeEnv { ["SINCH_SERVICE_PLAN_ID"] = "plan" }; + var req = new SinchProvider().BuildRequest("sms", "https://sms.api.sinch.com", + Disp(message: "code 918273"), new ProviderCredential("apiKey", Secret: "token"), env); + Assert.Equal("Bearer token", req.Headers["Authorization"]); + Assert.EndsWith("/xms/v1/plan/batches", req.Url); } } diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 2f33322..cfeaa67 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -1,146 +1,326 @@ using System.Net; +using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Epp.Otp; using Epp.Otp.Providers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Xunit; namespace Epp.Otp.Tests; -// Engine-level conformance tests (CONTRACT.md §6) with a fake Key Vault, HTTP client, and env. public class EngineTests { + private const string Phone = "+15551234567"; + private const string Code = "918273"; + private const string Nonce = "private-nonce-value"; + private const string Token = "eyJhbGciOiJSUzI1NiJ9.private-jwt.signature"; + private const string Key = "private-provider-key"; + private const string Kid = "private-jwe-key-id"; + private const string BadCorrelation = "untrusted-correlation/\r\nforged-trace"; + private const string BadClientId = "untrusted-client-request-id"; + private const string TraceId = "A1234567890B1234C567890D123456EF"; + private const string GuidCorrelation = "a1234567-890b-1234-c567-890d123456ef"; + private static string SensitiveText => $"{Phone} {Code} {Nonce} {Token} {Key}"; + private sealed class FakeEnv : Dictionary, IEnv { - public string? Get(string key) => TryGetValue(key, out var value) ? value : null; + public string? ThrowOnKey { get; set; } + public string? Get(string key) => key == ThrowOnKey ? throw new InvalidOperationException(SensitiveText) + : TryGetValue(key, out var value) ? value : null; } - private sealed class FakeSecretResolver : ISecretResolver + private sealed class FakeSecretResolver(string secret) : ISecretResolver { - private readonly IReadOnlyDictionary _values; - public FakeSecretResolver(IReadOnlyDictionary values) => _values = values; - public Task ResolveAsync(string? secretName) => - Task.FromResult(secretName != null && _values.TryGetValue(secretName, out var value) ? value : string.Empty); + public Task ResolveAsync(string? secretName) => Task.FromResult(secret); } - private sealed class StubHandler : HttpMessageHandler + private sealed class StubHandler(Func responder) : HttpMessageHandler, IHttpClientFactory { - private readonly Func _responder; public string? LastBody; - public StubHandler(Func responder) => _responder = responder; + public string? LastAuthorization; + public string? LastUrl; + public int Calls; + public HttpClient CreateClient(string name) => new(this, disposeHandler: false); protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + Calls++; + LastAuthorization = request.Headers.Authorization?.ToString(); + LastUrl = request.RequestUri?.AbsoluteUri; if (request.Content != null) LastBody = await request.Content.ReadAsStringAsync(cancellationToken); - return _responder(request); + return responder(request); } } - private sealed class FakeHttpClientFactory : IHttpClientFactory + private sealed class FakeTokenAcquirer(string? token) : IProviderTokenAcquirer { - private readonly HttpMessageHandler _handler; - public FakeHttpClientFactory(HttpMessageHandler handler) => _handler = handler; - public HttpClient CreateClient(string name) => new(_handler); + public Task AcquireAsync(CancellationToken cancellationToken = default) => Task.FromResult(token!); } - private sealed class CapturingLogger : ILogger + private sealed class CapturingLogger : ILogger { - public readonly List Lines = new(); - public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public string Text = ""; + public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - => Lines.Add(formatter(state, exception)); - private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } + { + Text += formatter(state, exception) + "\n" + exception + "\n"; + if (state is IEnumerable> fields) + Text += string.Join("\n", fields.Select(pair => $"{pair.Key}={pair.Value}")) + "\n"; + } } - private static readonly Dictionary DefaultSecrets = new() - { - ["infobip-api-key"] = "ib", - ["telesign-api-key"] = "ts", ["telesign-customer-id"] = "cust", - }; - private static FakeEnv DefaultEnv() => new() { + ["EPP_PROVIDER_NAME"] = "infobip", ["EPP_PROVIDER_ENDPOINT"] = "https://api.infobip.com", + ["EPP_REQUIRE_AUTH"] = "false", }; - private static DispatchEngine Engine(HttpResponseMessage? response = null, Exception? throwOnSend = null, - IReadOnlyDictionary? secrets = null, FakeEnv? env = null, StubHandler? handler = null) + private static DispatchEngine Engine(StubHandler handler, FakeEnv? env = null, string secret = Key, + IProviderTokenAcquirer? tokenAcquirer = null) { - var registry = new ProviderRegistry(new IProviderAdapter[] { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() }); - var stub = handler ?? new StubHandler(_ => throwOnSend != null ? throw throwOnSend : response!); - return new DispatchEngine(registry, new FakeSecretResolver(secrets ?? DefaultSecrets), new FakeHttpClientFactory(stub), env ?? DefaultEnv()); + env ??= DefaultEnv(); + var registry = new ProviderRegistry(new IProviderAdapter[] { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() }, env); + return new DispatchEngine(registry, new FakeSecretResolver(secret), handler, env, tokenAcquirer); } - private static DispatchRequest Disp(string channel = "sms", string? message = "Your code is 918273") => - new("+15551234567", message, channel, "m", "c", null); + private static DispatchRequest Disp(string channel = "sms") => new(Phone, "Your code is 918273", channel, "m", "c", null); private static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; - [Fact] - public async Task UnknownProvider_400() + private static HttpResponseMessage ProviderReply(HttpStatusCode status = HttpStatusCode.OK, bool knownStatus = true) => + Json(status, JsonSerializer.Serialize(new + { + messages = new[] { new { status = new { groupName = knownStatus ? "DELIVERED" : SensitiveText, + name = SensitiveText, description = SensitiveText }, messageId = SensitiveText } }, + })); + + private static void AssertNoSecrets(string text, params string[] additional) + { + foreach (var secret in new[] { Phone, Phone.TrimStart('+'), Phone[^10..], Code, Nonce, Token, Key, Kid }.Concat(additional)) + Assert.DoesNotContain(secret, text, StringComparison.OrdinalIgnoreCase); + } + + private static void AssertPrivate(CapturingLogger logger, params string[] additional) { - var result = await Engine(Json(HttpStatusCode.OK, "{}")).DispatchAsync(Disp(), "nope", false, "r", new CapturingLogger()); - Assert.Equal(400, result.HttpStatus); + Assert.NotEmpty(logger.Text); + AssertNoSecrets(logger.Text, additional.Concat(new[] { BadCorrelation, BadClientId }).ToArray()); } [Fact] - public async Task MissingCredential_502() + public void TraceIds_NormalizeGuidsOrUseLabeledHash() { - var result = await Engine(Json(HttpStatusCode.OK, "{}"), secrets: new Dictionary()).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); - Assert.Equal(502, result.HttpStatus); + Assert.Equal(GuidCorrelation, DispatchEngine.SafeTraceId(TraceId)); + Assert.Equal("unknown", DispatchEngine.SafeTraceId(null)); + Assert.Equal("sha256:ba7816bf8f01cfea414140de", DispatchEngine.SafeTraceId("abc")); } [Fact] - public async Task MissingEndpoint_502() + public async Task MissingCredential_DoesNotSend() + { + var handler = new StubHandler(_ => throw new Exception("must not send")); + var failed = await Engine(handler, secret: "") + .DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); + Assert.Equal(502, failed.HttpStatus); + Assert.Equal(0, handler.Calls); + Assert.Equal("provider credential unavailable", JsonSerializer.SerializeToElement(failed.Body).GetProperty("reason").GetString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfiguredAndAlternateEndpoints_MustBeHttps(bool alternate) { - var result = await Engine(Json(HttpStatusCode.OK, "{}"), env: new FakeEnv()).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); + var handler = new StubHandler(_ => throw new Exception("insecure URL must not be called")); + var env = new FakeEnv + { + ["EPP_PROVIDER_ENDPOINT"] = alternate ? "https://sms.api.sinch.com" : "http://api.example.com", + ["SINCH_VOICE_ENDPOINT"] = "http://localhost:8080", + }; + var result = await Engine(handler, env) + .DispatchAsync(Disp(alternate ? "voice" : "sms"), alternate ? "sinch" : "infobip", false, "r", new CapturingLogger()); Assert.Equal(502, result.HttpStatus); + Assert.Equal(0, handler.Calls); } - [Fact] - public async Task Shutter_DoesNotSend_200() + [Theory] + [InlineData(401, true, 401)] + [InlineData(429, true, 429)] + [InlineData(500, true, 502)] + [InlineData(200, false, 502)] + public async Task HandlerProviderFailure_MapsStatusWithoutLeakingDiagnostics(int httpStatus, bool knownStatus, int expected) { - var handler = new StubHandler(_ => throw new Exception("should not send")); - var result = await Engine(handler: handler).DispatchAsync(Disp(), "infobip", true, "r", new CapturingLogger()); - Assert.Equal(200, result.HttpStatus); - Assert.Null(handler.LastBody); + using var rsa = RSA.Create(2048); + var jwe = EncryptContext(rsa); + var stub = new StubHandler(_ => ProviderReply((HttpStatusCode)httpStatus, knownStatus)); + var logger = new CapturingLogger(); + var response = await RunHandler(JsonSerializer.Serialize(EnvelopePayload(jwe)), rsa, DefaultEnv(), stub, logger); + Assert.Equal(expected, response.StatusCode); + Assert.Equal(1, stub.Calls); + var body = JsonSerializer.SerializeToElement(response.Value); + Assert.Equal("delivery_failed", body.GetProperty("error").GetString()); + Assert.Equal("provider delivery failed", body.GetProperty("reason").GetString()); + Assert.Equal(BadCorrelation, body.GetProperty("correlationId").GetString()); + var requestId = body.GetProperty("requestId").GetString()!; + Assert.True(Guid.TryParseExact(requestId, "N", out var id)); + Assert.Contains($"RequestId={id:D}", logger.Text); + Assert.DoesNotContain(requestId, logger.Text); + AssertNoSecrets(body.ToString(), jwe, BadClientId); + AssertPrivate(logger, jwe); } - [Fact] - public async Task Success_RendersCode_AndKeepsPrivacy() + [Theory] + [InlineData(true, 504, "endpoint timeout after 1500ms")] + [InlineData(false, 502, "provider request failed")] + public async Task TransportFailure_IsPrivateAndNotRetried(bool timeout, int status, string reason) { - var handler = new StubHandler(_ => Json(HttpStatusCode.OK, "{\"messages\":[{\"status\":{\"name\":\"DELIVERED\"},\"messageId\":\"x\"}]}")); var logger = new CapturingLogger(); + var handler = new StubHandler(_ => throw (timeout ? (Exception)new TaskCanceledException(SensitiveText) : new HttpRequestException(SensitiveText))); var result = await Engine(handler: handler).DispatchAsync(Disp(), "infobip", false, "r", logger); + Assert.Equal(status, result.HttpStatus); + Assert.Equal(1, handler.Calls); + var body = JsonSerializer.SerializeToElement(result.Body); + Assert.Equal(reason, body.GetProperty("reason").GetString()); + AssertNoSecrets(body.ToString()); + AssertPrivate(logger); + } - Assert.Equal(200, result.HttpStatus); - Assert.Contains("918273", handler.LastBody); // message (with the code) IS sent to the provider - var bodyJson = System.Text.Json.JsonSerializer.Serialize(result.Body); - Assert.DoesNotContain("918273", bodyJson); // never in the response body - Assert.DoesNotContain("5551234567", bodyJson); - Assert.All(logger.Lines, line => Assert.DoesNotContain("918273", line)); // never logged - Assert.All(logger.Lines, line => Assert.DoesNotContain("5551234567", line)); + [Theory] + [InlineData(Token, 200)] + [InlineData(null, 502)] + public async Task OAuthMode_SendsOnlyWithMintedBearer(string? token, int status) + { + var handler = new StubHandler(_ => Json(HttpStatusCode.Created, "{\"status\":\"ENROUTE\"}")); + var env = new FakeEnv { ["EPP_PROVIDER_ENDPOINT"] = "https://api.soprano.com", ["EPP_PROVIDER_AUTH_MODE"] = "oauth2" }; + var logger = new CapturingLogger(); + var result = await Engine(handler, env, tokenAcquirer: new FakeTokenAcquirer(token)) + .DispatchAsync(Disp(), "soprano", false, "r", logger); + + Assert.Equal(status, result.HttpStatus); + Assert.Equal(status == 200 ? 1 : 0, handler.Calls); + if (status == 200) + { + Assert.Equal($"Bearer {Token}", handler.LastAuthorization); + Assert.EndsWith("/messages/omnimsg", handler.LastUrl); + } + AssertNoSecrets(JsonSerializer.Serialize(result.Body)); + AssertPrivate(logger); } - [Fact] - public async Task UnknownStatus_FailsClosed() + private static string EncryptContext(RSA rsa, string? message = "Your code is 918273") => + Jose.JWT.Encode(JsonSerializer.Serialize(new { nonce = Nonce, phoneNumber = Phone, message, locale = "en-US" }), + rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM, + extraHeaders: new Dictionary { ["kid"] = Kid }); + + private static Dictionary EnvelopePayload(string jwe) => new() { - var result = await Engine(Json(HttpStatusCode.OK, "{\"messages\":[{\"status\":{\"name\":\"WATWAT\"}}]}")).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); - Assert.Equal(502, result.HttpStatus); // Fail on HTTP 200 -> 502 + ["type"] = EnvelopeParser.EnvelopeType, ["channel"] = 1, ["mode"] = 1, + ["correlationId"] = BadCorrelation, ["tenantId"] = "private-tenant-id", ["encryptedDeliveryContext"] = jwe, + }; + + private static async Task RunHandler(string body, RSA rsa, FakeEnv env, StubHandler stub, + CapturingLogger logger, Exception? keyFailure = null) + { + var req = new DefaultHttpContext().Request; + req.Method = "POST"; + req.ContentType = "application/json"; + req.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); + req.Headers.Authorization = $"Bearer {Token}"; + req.Headers["x-ms-correlation-id"] = GuidCorrelation; + req.Headers["x-ms-client-request-id"] = BadClientId; + req.Headers["x-ms-client-principal"] = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new + { + claims = new[] { new { typ = "appid", val = "private-caller-id" } }, + }))); + var handler = new SendOtp(Engine(stub, env), new TokenValidator(env), + new JweDecryptor(new EnvelopeTests.FakeKeyProvider(rsa, keyFailure)), env, logger); + using var stream = req.Body; + return Assert.IsType(await handler.Run(req)); } - [Fact] - public async Task Timeout_504() + [Theory] + [InlineData("sms", false)] + [InlineData("sms", true)] + [InlineData("voice", false)] + public async Task HandlerSuccess_PreservesCallerRenderedMessageAndWireValues(string channel, bool evaluation) { - var result = await Engine(throwOnSend: new TaskCanceledException()).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); - Assert.Equal(504, result.HttpStatus); + var message = channel == "voice" + ? "Your code is 9 1 8 2 7 3; reference 123456, year 2026. Call +1 (555) 123-4567!" + : "Your code is 918273"; + using var rsa = RSA.Create(2048); + var jwe = EncryptContext(rsa, message); + var payload = EnvelopePayload(jwe); + payload["channel"] = channel == "voice" ? 2 : 1; + payload["mode"] = evaluation ? 2 : 1; + var env = DefaultEnv(); + if (evaluation) + { + payload.Remove("correlationId"); + env.Remove("EPP_PROVIDER_ENDPOINT"); + env.ThrowOnKey = "EPP_PROVIDER_AUTH_MODE"; + } + var logger = new CapturingLogger(); + var stub = new StubHandler(_ => ProviderReply()); + var response = await RunHandler(JsonSerializer.Serialize(payload), rsa, env, stub, logger); + Assert.Equal(200, response.StatusCode); + var body = JsonSerializer.SerializeToElement(response.Value); + var correlation = evaluation ? GuidCorrelation : BadCorrelation; + Assert.All(body.EnumerateObject(), property => Assert.Contains(property.Name, new[] { "nonce", "correlationId", "providerStatus" })); + Assert.Equal(Nonce, body.GetProperty("nonce").GetString()); + Assert.Equal(correlation, body.GetProperty("correlationId").GetString()); + Assert.Equal("accepted", body.GetProperty("providerStatus").GetString()); + Assert.Equal(evaluation ? 0 : 1, stub.Calls); + if (!evaluation) + { + Assert.Equal($"App {Key}", stub.LastAuthorization); + using var sent = JsonDocument.Parse(stub.LastBody!); + var sentMessage = sent.RootElement.GetProperty("messages")[0]; + var destination = sentMessage.GetProperty("destinations")[0]; + Assert.Equal(Phone, destination.GetProperty("to").GetString()); + Assert.Equal(correlation, destination.GetProperty("messageId").GetString()); + var content = channel == "voice" ? sentMessage : sentMessage.GetProperty("content"); + Assert.Equal(message, content.GetProperty("text").GetString()); + Assert.EndsWith(channel == "voice" ? "/tts/3/advanced" : "/sms/3/messages", stub.LastUrl); + } + Assert.Contains($"CorrelationId={DispatchEngine.SafeTraceId(correlation)}", logger.Text); + AssertPrivate(logger, jwe, message, "9 1 8 2 7 3", "private-tenant-id", "private-caller-id", rsa.ExportRSAPrivateKeyPem()); } - [Fact] - public async Task NetworkError_502() + [Theory] + [InlineData("unexpected_caller", 403, "unexpected_caller")] + [InlineData("unauthorized", 401, "unauthorized")] + [InlineData("invalid_json", 400, "bad_request")] + [InlineData("decryption_failed", 400, "decryption_failed")] + [InlineData("incomplete_context", 400, "bad_request")] + [InlineData("internal_failure", 500, "delivery_failed")] + public async Task HandlerFailure_ReturnsPrivateErrorWithoutSending(string reason, int status, string error) { - var result = await Engine(throwOnSend: new HttpRequestException("dns")).DispatchAsync(Disp(), "infobip", false, "r", new CapturingLogger()); - Assert.Equal(502, result.HttpStatus); + using var rsa = RSA.Create(2048); + var jwe = EncryptContext(rsa, reason == "incomplete_context" ? null : "Your code is 918273"); + var payload = EnvelopePayload(jwe); + var env = DefaultEnv(); + switch (reason) + { + case "unexpected_caller": env["EPP_EXPECTED_CLIENT_ID"] = "private-expected-id"; break; + case "unauthorized": env["EPP_REQUIRE_AUTH"] = "true"; break; // No audience/tenant: reject without OIDC traffic. + case "internal_failure": env.ThrowOnKey = "EPP_PROVIDER_ACCOUNT_NAME"; break; + } + var logger = new CapturingLogger(); + var stub = new StubHandler(_ => throw new Exception("must not send")); + var keyFailure = reason == "decryption_failed" ? new InvalidOperationException(SensitiveText) : null; + var requestBody = reason == "invalid_json" ? "not-json " + SensitiveText : JsonSerializer.Serialize(payload); + var response = await RunHandler(requestBody, rsa, env, stub, logger, keyFailure: keyFailure); + Assert.Equal(status, response.StatusCode); + var body = JsonSerializer.SerializeToElement(response.Value); + Assert.Equal(error, body.GetProperty("error").GetString()); + Assert.All(body.EnumerateObject(), property => Assert.Contains(property.Name, new[] { "error", "reason", "correlationId", "requestId" })); + AssertNoSecrets(body.ToString(), jwe, BadClientId, "private-caller-id", "private-expected-id"); + if (reason == "internal_failure") Assert.False(body.TryGetProperty("reason", out _)); + Assert.Equal(0, stub.Calls); + AssertPrivate(logger, jwe, "private-tenant-id", "private-caller-id", "private-expected-id"); } } diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs index 9baede5..556058f 100644 --- a/dotnet/tests/EnvelopeTests.cs +++ b/dotnet/tests/EnvelopeTests.cs @@ -5,52 +5,79 @@ namespace Epp.Otp.Tests; -// Envelope validation + JWE decryption round-trip (see docs/CONTRACT.md §1, §6). public class EnvelopeTests { - private static JsonElement Payload(string json) => JsonDocument.Parse(json).RootElement; + private static JsonElement Payload(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } - private sealed class FakeKeyProvider : IJweKeyProvider + internal sealed class FakeKeyProvider(RSA rsa, Exception? failure = null) : IJweKeyProvider { - private readonly RSA _rsa; - public FakeKeyProvider(RSA rsa) => _rsa = rsa; - public RSA GetPrivateKey(string? kid) => _rsa; + public RSA GetPrivateKey(string? kid) => failure is not null ? throw failure : rsa; } [Fact] public void MissingEncryptedContext_IsError() { - var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":1,\"mode\":1}")); + var (envelope, error) = EnvelopeParser.Parse(Payload("{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":1,\"mode\":1}")); Assert.Null(envelope); Assert.Contains("encryptedDeliveryContext", error); } - [Fact] - public void UnsupportedChannel_IsError() + [Theory] + [InlineData("type", "\"phone=+15551234567 code=918273 token=private\"")] + [InlineData("channel", "9")] + [InlineData("mode", "true")] + public void InvalidRouting_IsRejectedWithoutEchoingInput(string field, string value) { - var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":9,\"mode\":1,\"encryptedDeliveryContext\":\"x\"}")); + var payload = new Dictionary + { + ["type"] = EnvelopeParser.EnvelopeType, ["channel"] = 1, ["mode"] = 1, + ["encryptedDeliveryContext"] = "x", + }; + payload[field] = Payload(value); + var (envelope, error) = EnvelopeParser.Parse(JsonSerializer.SerializeToElement(payload)); Assert.Null(envelope); - Assert.Contains("channel", error); + Assert.Equal($"unsupported {field}", error); } - [Fact] - public void UnsupportedMode_IsError() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ValidEnvelope_ParsesWithOptionalTtl(bool includeTtl) { - var (envelope, error) = EnvelopeParser.Parse(Payload("{\"channel\":1,\"mode\":5,\"encryptedDeliveryContext\":\"x\"}")); + var ttl = includeTtl ? ",\"ttlSeconds\":60" : ""; + var (envelope, error) = EnvelopeParser.Parse(Payload( + $"{{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"tenantId\":\"t\",\"correlationId\":\"c\",\"channel\":2,\"mode\":1,\"encryptedDeliveryContext\":\"x\"{ttl}}}")); + Assert.Null(error); + Assert.NotNull(envelope); + Assert.Equal(2, envelope.Channel); + Assert.Equal(1, envelope.Mode); + Assert.Equal(includeTtl ? (int?)60 : null, envelope.TtlSeconds); + Assert.Equal("voice", EnvelopeParser.ChannelName(envelope.Channel)); + } + + [Theory] + [InlineData("\"0\"")] + [InlineData("0.5")] + [InlineData("null")] + public void MalformedTtl_IsError(string ttlJson) + { + var (envelope, error) = EnvelopeParser.Parse(Payload( + $"{{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":1,\"mode\":1,\"ttlSeconds\":{ttlJson},\"encryptedDeliveryContext\":\"x\"}}")); Assert.Null(envelope); - Assert.Contains("mode", error); + Assert.Contains("positive integer", error); } [Fact] - public void ValidEnvelope_Parses() + public void ExpiredTtl_IsError() { var (envelope, error) = EnvelopeParser.Parse(Payload( - "{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"tenantId\":\"t\",\"correlationId\":\"c\",\"channel\":2,\"mode\":1,\"ttlSeconds\":60,\"encryptedDeliveryContext\":\"x\"}")); - Assert.Null(error); - Assert.NotNull(envelope); - Assert.Equal(2, envelope!.Channel); - Assert.Equal(1, envelope.Mode); - Assert.Equal("voice", EnvelopeParser.ChannelName(envelope.Channel)); + "{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":1,\"mode\":1,\"ttlSeconds\":0,\"encryptedDeliveryContext\":\"x\"}")); + Assert.Null(envelope); + Assert.Contains("expired", error); } [Fact] diff --git a/dotnet/tests/Epp.Otp.Tests.csproj b/dotnet/tests/Epp.Otp.Tests.csproj index d103f94..fbce471 100644 --- a/dotnet/tests/Epp.Otp.Tests.csproj +++ b/dotnet/tests/Epp.Otp.Tests.csproj @@ -8,6 +8,9 @@ + + + @@ -16,13 +19,13 @@ - - + + diff --git a/javascript/README.md b/javascript/README.md index ea6925c..f4e58ff 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -25,7 +25,7 @@ provider (**Infobip**, **Telesign**, **Sinch**, or **Soprano**). 4. **Publish:** ```bash -cd src +cd javascript npm install func azure functionapp publish ``` @@ -60,17 +60,33 @@ Key Vault and can be rotated there without a redeploy. | `EPP_PROVIDER_NAME` | your chosen provider: `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 provider-call timeout in ms (default `1500`) | +| `EPP_PROVIDER_TIMEOUT_MS` | outbound provider-call timeout in ms (default `1500`, capped at `2500`) | | `EPP_DECRYPTION_KEY_PEM` | RSA private key PEM for JWE decryption — 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; ignored. The configured PEM decrypts the JWE; `kid` is never logged | | `EPP_EXPECTED_CLIENT_ID` | caller `appid`/`azp` to admit; Easy Auth returns `403`, in-process validation returns `401` | | `EPP_REQUIRE_AUTH` | **set `true` in any real deployment** — validates the token in-process as a backstop to Easy Auth | | `EPP_EXPECTED_AUDIENCE` | token `aud` (this endpoint's app registration appId) — required when `EPP_REQUIRE_AUTH=true` | | `EPP_TENANT_ID` | customer tenant id for issuer/JWKS — required when `EPP_REQUIRE_AUTH=true` | | `EPP_EXPECTED_ISSUER` | optional; pins a single issuer instead of accepting both v1 and v2 | -| `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, including when `true`; plaintext logging is never enabled | | `KEY_VAULT_URL` | Key Vault URI (provider API keys) | +### Privacy and observability + +Each invocation emits one `[EPP]` summary with a generated `requestId`, log-safe `correlationId`, +numeric `httpStatus` / `elapsedMs`, boolean `nonceEcho` / `evaluation`, and fixed `reason` / `outcome`. +Separate `[DISPATCH]` traces contain only a log-safe request ID. Canonical hyphenated GUIDs remain +visible; other IDs use a labeled hash. Wire IDs are not rewritten. + +Phone numbers, OTPs, nonce values, tokens, keys, caller metadata, provider status text and raw bodies +are never logged, including in diagnostics. Exceptions and SDK objects are not logged. Application +logs cannot prevent separately enabled platform, SDK or proxy body capture; keep that disabled too. + +See [../docs/CONTRACT.md](../docs/CONTRACT.md) for the wire contract. Correlation and attempt IDs are +preserved for dispatch, including header fallback. Only success echoes the actual +nonce. Provider failures keep their HTTP status and return only +`{ error: 'provider_delivery_failed', correlationId, requestId }`, never the provider's body. + ### Per-provider settings (set only for the provider you chose) Set `EPP_PROVIDER_NAME` to your provider, then provision **only that block** — its **Key Vault secret** @@ -100,14 +116,23 @@ are all non-secret configuration; only the key/token **value** lives in Key Vaul | `SINCH_SENDER_ID` → `EPP_PROVIDER_ACCOUNT_NAME` | sender, app setting (default `Verify`) | | `SINCH_VOICE_ENDPOINT` | Sinch Voice API host, app setting (optional; default `https://calling.api.sinch.com`) | -**Soprano** +**Soprano** — posts to `{endpoint}/messages/omnimsg`, one endpoint for every channel: `messageTypes` +selects `sms` or `voice`, Soprano renders the TTS itself, and the sender comes from the account +provisioning rather than the request. + | Setting | Purpose | |---------|---------| | Key Vault secret `soprano-api-key` | API key (sent as the `X-MEMS-API-Key` header) | | Key Vault secret `soprano-api-id` | API ID (sent as the `X-MEMS-API-ID` header) | | `EPP_PROVIDER_ENDPOINT` | **required** — your MEMS API base `https:///cgpapi` (per-customer; no default) | -| `EPP_PROVIDER_ACCOUNT_NAME` | the provisioned source/sender endpoint id — Soprano requires a provisioned sender, so a **numeric** value is sent as `endpoints:[{type,id}]`; a non-numeric one falls back to a free-text `source` | -| `SOPRANO_SOURCE_TYPE` | provisioned source endpoint type, app setting (optional; default `1`) | +| `SOPRANO_SHUTTER_MODE` | **diagnostics only** — `true` sends `shutterMode`, so Soprano accepts the request and delivers nothing while every layer still reports success. SAS sees a 2xx with a matching nonce and will **not** fall back, so the user gets no passcode at all. Never enable in production | + +> Soprano voice ignores `locale`: omnimsg takes no language field, so the account's default TTS voice +> is used regardless of the caller's locale. + +> Soprano also accepts an **Entra ID v2.0** client-credentials Bearer token (audience = Soprano's app +> registration id) in place of the `X-MEMS-*` headers; the adapter sends `Authorization: Bearer` when the +> credential resolves in `oauth2` mode. > `EPP_PROVIDER_ENDPOINT` is the provider base URL for the one active provider (e.g. a sandbox host). diff --git a/javascript/src/functions/SendOtp.js b/javascript/src/functions/SendOtp.js index 7d30ebc..dca016a 100644 --- a/javascript/src/functions/SendOtp.js +++ b/javascript/src/functions/SendOtp.js @@ -4,9 +4,7 @@ 'use strict'; -// POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the caller, parses -// the cleartext routing envelope, decrypts the JWE delivery context, dispatches to the provider, and -// echoes the nonce to prove decryption. Every line is tagged [EPP] so one filter pulls a whole delivery. +// POST /api/SendOtp: authenticate, decrypt, dispatch, then echo the nonce on acceptance only. const { app } = require('@azure/functions'); const crypto = require('crypto'); @@ -17,12 +15,11 @@ const { decryptDeliveryContext, contextToDispatch, MODE, + safeTraceId, } = require('./dispatch'); -const { readConfig, missingSettings } = require('./config'); +const { readConfig } = require('./config'); -const TAG = '[EPP]'; - -// Easy Auth has already validated the token by the time this runs; this records which identity arrived. +// Easy Auth is the primary gate; enforce its caller allowlist here as well. function readCallerAppId(request) { const encoded = request.headers.get('x-ms-client-principal'); if (!encoded) return undefined; @@ -35,156 +32,81 @@ function readCallerAppId(request) { } } -const pad = (label) => label.padEnd(18, ' '); - -// The handler deliberately does not await the provider, so tests need a handle on the send it started. -let pendingDelivery = Promise.resolve(); -const whenDelivered = () => pendingDelivery; - -// Microsoft allows 3.2 s for the whole call, so the provider is called after the response. -function deliverInBackground(dispatch, evaluation, context, requestId) { - pendingDelivery = dispatchOtp(dispatch, { shutter: evaluation, context, requestId }) - .then(({ httpStatus, body }) => { - context.log(`${TAG} provider result : httpStatus=${httpStatus} outcome=${body.outcome || 'n/a'} providerStatus=${body.providerStatus || 'n/a'} providerMessageId=${body.providerMessageId || 'n/a'}`); - }) - .catch((deliveryError) => { - (context.error || context.log).call(context, `${TAG} provider delivery failed: ${deliveryError.message}`); - }); - return pendingDelivery; -} - app.http('SendOtp', { methods: ['POST'], authLevel: 'anonymous', // Easy Auth is the gate; EPP_REQUIRE_AUTH adds in-process token validation. handler: async (request, context) => { const started = Date.now(); - const config = readConfig(); - const log = (label, value) => context.log(`${TAG} ${pad(label)}: ${value}`); - const warn = (message) => (context.warn || context.log).call(context, `${TAG} ${message}`); - const error = (message) => (context.error || context.log).call(context, `${TAG} ${message}`); - const requestId = crypto.randomUUID(); - const clientRequestId = request.headers.get('x-ms-client-request-id') || requestId; - const headerCorrelationId = request.headers.get('x-ms-correlation-id') || null; - - context.log(`${TAG} ======== delivery received ========`); - log('invocation', context.invocationId || requestId); - - let envelope; + let correlationId = requestId; + let evaluation = false; + const reply = (status, jsonBody, reason, outcome = status === 200 ? 'Continue' : 'Fail') => { + context.log('[EPP]', { + requestId, correlationId: safeTraceId(correlationId), httpStatus: status, + elapsedMs: Date.now() - started, nonceEcho: status === 200 && !!jsonBody.nonce, + evaluation, reason, outcome, + }); + return { status, jsonBody }; + }; try { - // Logged, not thrown: a missing provider setting still lets this prove decryption works. - const absent = missingSettings(config); - if (absent.length > 0) { - warn(`settings not set: ${absent.join(', ')}`); - } - + const config = readConfig(); + correlationId = request.headers.get('x-ms-correlation-id') || requestId; + const clientRequestId = request.headers.get('x-ms-client-request-id') || requestId; const callerAppId = readCallerAppId(request); - log('caller appid', callerAppId || 'none (Easy Auth off, or called directly)'); - if (callerAppId && config.expectedClientId && callerAppId !== config.expectedClientId) { - error(`caller ${callerAppId} is not ${config.expectedClientId}. ` + - 'Easy Auth allowedApplications is not doing its job.'); - return { status: 403, jsonBody: { error: 'unexpected_caller' } }; + return reply(403, { error: 'unexpected_caller' }, 'unexpected_caller'); } - const tokenValidation = await validateToken(request, context, requestId); + const tokenValidation = await validateToken(request); if (!tokenValidation.ok) { - error(`token rejected: ${tokenValidation.reason}`); - return { status: 401, jsonBody: { error: 'unauthorized', reason: tokenValidation.reason, requestId } }; + return reply(401, { error: 'unauthorized', reason: tokenValidation.reason, requestId }, 'unauthorized'); } let payload; try { payload = JSON.parse(await request.text()); - } catch (parseError) { - error(`body is not JSON: ${parseError.message}`); - return { status: 400, jsonBody: { error: 'bad_request', reason: 'invalid JSON body', requestId } }; + } catch { + return reply(400, { error: 'bad_request', reason: 'invalid JSON body', requestId }, 'invalid_json'); } const parsed = parseEnvelope(payload); if (parsed.error) { - error(`envelope rejected: ${parsed.error}`); - return { status: 400, jsonBody: { error: 'bad_request', reason: parsed.error, requestId } }; + return reply(400, { error: 'bad_request', reason: parsed.error, requestId }, 'invalid_envelope'); } - envelope = parsed.envelope; - - log('type', envelope.type); - log('tenantId', envelope.tenantId); - log('correlationId', envelope.correlationId); - log('channel', envelope.channel); - log('mode', envelope.mode); - log('ttlSeconds', envelope.ttlSeconds); + const envelope = parsed.envelope; + correlationId = envelope.correlationId || correlationId; + evaluation = envelope.mode === MODE.EVALUATION; - const correlationId = envelope.correlationId || headerCorrelationId || requestId; - - // Surfaced rather than swallowed: the passcode expires before it can be used. - if (envelope.ttlSeconds !== undefined && envelope.ttlSeconds <= 0) { - warn(`ttlSeconds is ${envelope.ttlSeconds}; the passcode has expired.`); - } - - let header; let delivery; try { - ({ header, context: delivery } = await decryptDeliveryContext( + ({ context: delivery } = await decryptDeliveryContext( envelope.encryptedDeliveryContext, config)); - } catch (decryptError) { - error(`decryption failed: ${decryptError.message}`); - return { status: 400, jsonBody: { error: 'decryption_failed', correlationId, requestId } }; - } - - const kidMatches = !config.expectedKeyId || header.kid === config.expectedKeyId; - log('kid', `${header.kid}${kidMatches ? '' : ' (DOES NOT match EPP_ENCRYPTION_KEY_ID)'}`); - log('alg / enc', `${header.alg} / ${header.enc}`); - log('decrypted', 'OK'); - log('nonce', delivery.nonce); - - if (config.logPlaintext) { - // DIAGNOSTICS ONLY — writes the phone number and passcode to the log. - log('phoneNumber', delivery.phoneNumber); - log('extension', delivery.extension || '(none)'); - log('locale', delivery.locale); - log('message', delivery.message); - log('riskContext', delivery.riskContext ? JSON.stringify(delivery.riskContext) : '(none)'); - } else { - context.log(`${TAG} plaintext suppressed (EPP_LOG_PLAINTEXT=false)`); + } catch { + return reply(400, { error: 'decryption_failed', correlationId, requestId }, 'decryption_failed'); } if (!delivery.nonce || !delivery.phoneNumber || !delivery.message) { - error('delivery context is incomplete (nonce/phoneNumber/message)'); - return { status: 400, jsonBody: { error: 'bad_request', reason: 'incomplete delivery context', correlationId, requestId } }; + return reply(400, { error: 'bad_request', reason: 'incomplete delivery context', correlationId, requestId }, 'incomplete_context'); } - const evaluation = envelope.mode === MODE.EVALUATION; - const dispatch = contextToDispatch(delivery, envelope, clientRequestId); - - deliverInBackground(dispatch, evaluation, context, requestId); - - // Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery - // and Microsoft re-sends over its own telephony, so the user gets the code twice. - const body = { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }; - - log('responding', `200, nonce echoed, ${Date.now() - started} ms`); - context.log(`${TAG} ======== done ========`); - - return { status: 200, jsonBody: body }; - } catch (unhandled) { - // Verbose on purpose: this endpoint exists to diagnose onboarding. - error(`FAILED after ${Date.now() - started} ms: ${unhandled.message}`); - if (unhandled.cause) { - error(`caused by: ${unhandled.cause.message || unhandled.cause}`); + const dispatch = contextToDispatch(delivery, { ...envelope, correlationId }, clientRequestId); + + const providerResult = await dispatchOtp(dispatch, { + shutter: evaluation, + context, + requestId, + }); + if (providerResult.httpStatus !== 200) { + return reply(providerResult.httpStatus, + { error: 'provider_delivery_failed', correlationId, requestId }, 'provider_delivery_failed', + providerResult.httpStatus === 403 ? 'Block' : providerResult.httpStatus === 409 ? 'StepUp' : 'Fail'); } - context.log(`${TAG} ======== failed ========`); - return { - status: 500, - jsonBody: { - error: 'delivery_failed', - detail: unhandled.message, - correlationId: envelope && envelope.correlationId, - }, - }; + // SAS treats a 2xx without the matching nonce as failure and falls back to native delivery. + return reply(200, { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }, + evaluation ? 'evaluation' : 'accepted'); + } catch { + return reply(500, { error: 'delivery_failed', correlationId, requestId }, 'delivery_failed'); } }, }); - -module.exports = { whenDelivered }; diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js index 79be0f2..1579114 100644 --- a/javascript/src/functions/config.js +++ b/javascript/src/functions/config.js @@ -10,29 +10,8 @@ function readConfig() { const env = process.env; return { decryptionKeyPem: env.EPP_DECRYPTION_KEY_PEM || '', - expectedKeyId: env.EPP_ENCRYPTION_KEY_ID || '', - expectedAudience: env.EPP_EXPECTED_AUDIENCE || '', expectedClientId: env.EPP_EXPECTED_CLIENT_ID || '', - tenantId: env.EPP_TENANT_ID || '', - // PII in the log. Diagnostics only, and must stay false in production. - logPlaintext: String(env.EPP_LOG_PLAINTEXT || '').toLowerCase() === 'true', - requireAuth: String(env.EPP_REQUIRE_AUTH || '').toLowerCase() === 'true', - provider: { - name: env.EPP_PROVIDER_NAME || '', - endpoint: env.EPP_PROVIDER_ENDPOINT || '', - }, }; } -// Reported, never thrown: a missing provider setting still lets the delivery prove decryption. -function missingSettings(config) { - const absent = []; - if (!config.decryptionKeyPem) absent.push('EPP_DECRYPTION_KEY_PEM'); - if (!config.provider.name) absent.push('EPP_PROVIDER_NAME'); - if (!config.provider.endpoint) absent.push('EPP_PROVIDER_ENDPOINT'); - if (config.requireAuth && !config.expectedAudience) absent.push('EPP_EXPECTED_AUDIENCE'); - if (config.requireAuth && !config.tenantId) absent.push('EPP_TENANT_ID'); - return absent; -} - -module.exports = { readConfig, missingSettings }; +module.exports = { readConfig }; diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index 61ba4ae..3bb7d49 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -4,54 +4,88 @@ 'use strict'; -// Delivery pipeline: parse the cleartext SAS envelope, decrypt the JWE that carries the PII, then -// dispatch to the configured provider. Fail-closed — only a Continue outcome is "accepted". - const crypto = require('crypto'); const { compactDecrypt } = require('jose'); -const { ManagedIdentityCredential } = require('@azure/identity'); +const { ManagedIdentityCredential, ClientSecretCredential, ClientAssertionCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('./config'); -// CyotChannel: 1=Sms, 2=Voice. CyotDeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). +const ENVELOPE_TYPE = 'microsoft.mfa.otpDeliver.v1'; +// Channel: 1=sms, 2=voice. Mode: 1=live (deliver), 2=evaluation (do not deliver). const CHANNEL_BY_CODE = Object.freeze({ 1: 'sms', 2: 'voice' }); const CHANNEL_BY_NAME = Object.freeze({ sms: 1, voice: 2 }); const MODE = Object.freeze({ LIVE: 1, EVALUATION: 2 }); const MODE_BY_NAME = Object.freeze({ live: 1, evaluation: 2 }); +const DEFAULT_PROVIDER_TIMEOUT_MILLISECONDS = 1500; +const MAX_PROVIDER_TIMEOUT_MILLISECONDS = 2500; + +// GUIDs remain joinable; hash everything else, including phone-like digits. Never change wire IDs. +function safeTraceId(value) { + const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''; + const groups = text.split('-'); + const groupLengths = [8, 4, 4, 4, 12]; + const isGuid = groups.length === groupLengths.length && groups.every((group, index) => + group.length === groupLengths[index] + && [...group.toLowerCase()].every((character) => '0123456789abcdef'.includes(character))); + return isGuid + ? text : `hash:${crypto.createHash('sha256').update(text).digest('hex').slice(0, 16)}`; +} -// channel/mode accept the int enum (1/2) or the string form ("sms"/"voice", "live"/"evaluation"). function normalizeChannel(channel) { - if (CHANNEL_BY_CODE[channel]) return Number(channel); - if (typeof channel === 'string' && CHANNEL_BY_NAME[channel.toLowerCase()]) return CHANNEL_BY_NAME[channel.toLowerCase()]; + if (channel === 1 || channel === 2) return channel; + if (typeof channel === 'string' && Object.hasOwn(CHANNEL_BY_NAME, channel.toLowerCase())) return CHANNEL_BY_NAME[channel.toLowerCase()]; return null; } function normalizeMode(mode) { if (mode === MODE.LIVE || mode === MODE.EVALUATION) return mode; - if (typeof mode === 'string' && MODE_BY_NAME[mode.toLowerCase()]) return MODE_BY_NAME[mode.toLowerCase()]; + if (typeof mode === 'string' && Object.hasOwn(MODE_BY_NAME, mode.toLowerCase())) return MODE_BY_NAME[mode.toLowerCase()]; return null; } function parseEnvelope(payload) { - if (!payload || typeof payload !== 'object') { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return { error: 'invalid envelope' }; } const { type, tenantId, correlationId, channel, mode, ttlSeconds, encryptedDeliveryContext } = payload; + if (type !== ENVELOPE_TYPE) { + return { error: 'unsupported type' }; + } if (typeof encryptedDeliveryContext !== 'string' || !encryptedDeliveryContext) { return { error: 'encryptedDeliveryContext is required' }; } const channelCode = normalizeChannel(channel); if (!channelCode) { - return { error: `unsupported channel '${channel}'` }; + return { error: 'unsupported channel' }; } const modeCode = normalizeMode(mode); if (!modeCode) { - return { error: `unsupported mode '${mode}'` }; + return { error: 'unsupported mode' }; + } + if (ttlSeconds !== undefined && !Number.isInteger(ttlSeconds)) { + return { error: 'ttlSeconds must be a positive integer' }; + } + if (ttlSeconds !== undefined && ttlSeconds <= 0) { + return { error: 'passcode has expired' }; } return { envelope: { type, tenantId, correlationId, channel: channelCode, mode: modeCode, ttlSeconds, encryptedDeliveryContext } }; } +function normalizeProviderTimeoutMilliseconds(value) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 + ? Math.min(parsed, MAX_PROVIDER_TIMEOUT_MILLISECONDS) + : DEFAULT_PROVIDER_TIMEOUT_MILLISECONDS; +} -// Reject oversized or structurally invalid JWEs before decoding or allocating buffers. +function isValidProviderEndpoint(value) { + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +// Reject oversized or malformed input before allocating buffers to decode it. const MAX_JWE_LENGTH = 16384; function assertWellFormedJwe(compactJwe) { @@ -72,12 +106,11 @@ function readProtectedHeader(compactJwe) { return JSON.parse(Buffer.from(protectedSegment, 'base64url').toString('utf8')); } -// Imported once: a per-delivery RSA import would sit inside the response budget. +// Cache the imported key: re-importing RSA on every delivery would eat the response budget. let cachedKey; let cachedKeyPem; -// The setup script stores the key as base64 over the PEM so its newlines survive being carried as an -// app setting, so accept either form. +// Base64-wrapped PEM preserves newlines in app settings. function normalizePem(value) { const text = String(value || ''); if (text.includes('-----BEGIN')) return text; @@ -96,13 +129,11 @@ function loadPrivateKey(pem) { return cachedKey; } -// Decrypts the JWE compact serialization. Returns the protected header (for kid/alg logging) alongside -// the CyotDeliveryContext. +// Pin alg/enc so a tampered header cannot downgrade the encryption. async function decryptDeliveryContext(compactJwe, config = readConfig()) { assertWellFormedJwe(compactJwe); const header = readProtectedHeader(compactJwe); const privateKey = loadPrivateKey(config.decryptionKeyPem); - // Pin alg/enc so a tampered header can't downgrade the crypto. const { plaintext } = await compactDecrypt(compactJwe, privateKey, { keyManagementAlgorithms: ['RSA-OAEP-256'], contentEncryptionAlgorithms: ['A256GCM'], @@ -110,17 +141,11 @@ async function decryptDeliveryContext(compactJwe, config = readConfig()) { return { header, context: JSON.parse(Buffer.from(plaintext).toString('utf8')) }; } -// Left alone, TTS reads 641895 as "six hundred forty-one thousand...", which no user can type. -function spacePasscodeForVoice(message) { - return String(message || '').replace(/\b\d{4,8}\b/, (digits) => digits.split('').join(' ')); -} - -// The message is pre-rendered and already contains the passcode, so there is no separate code field. function contextToDispatch(context, envelope, messageId) { const channel = CHANNEL_BY_CODE[envelope.channel]; return { destination: context.phoneNumber, - message: channel === 'voice' ? spacePasscodeForVoice(context.message) : context.message, + message: context.message, channel, messageId, correlationId: envelope.correlationId, @@ -128,7 +153,6 @@ function contextToDispatch(context, envelope, messageId) { }; } - const OUTCOME = Object.freeze({ CONTINUE: 'Continue', FAIL: 'Fail', @@ -136,32 +160,10 @@ const OUTCOME = Object.freeze({ STEP_UP: 'StepUp', }); -const HTTP_STATUS = Object.freeze({ - OK: 200, - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - CONFLICT: 409, - TOO_MANY_REQUESTS: 429, - BAD_GATEWAY: 502, - GATEWAY_TIMEOUT: 504, -}); +// Rotated secrets are picked up within this window. +const SECRET_CACHE_TIME_TO_LIVE_MILLISECONDS = 5 * 60 * 1000; -const RESPONSE_STATUS = Object.freeze({ - ACCEPTED: 'accepted', - FAILED: 'failed', - ERROR: 'error', -}); - -const DEFAULTS = Object.freeze({ - CHANNEL: 'sms', - ENDPOINT_TIMEOUT_MILLISECONDS: 1500, - CHANNELS: ['sms', 'voice'], -}); - -const SECRET_CACHE_TIME_TO_LIVE_MILLISECONDS = 5 * 60 * 1000; // rotated secrets picked up within this window - -// Onboarding a provider is a new file plus one line here — static, so a broken provider fails at load. +// Add a provider by writing an adapter module and listing it here; a broken adapter fails at load. const providerRegistry = new Map( [ require('./providers/infobip'), @@ -175,19 +177,18 @@ const providerRegistry = new Map( ); function getProvider(providerId) { - return providerId ? providerRegistry.get(String(providerId).toLowerCase()) || null : null; + return typeof providerId === 'string' ? providerRegistry.get(providerId.toLowerCase()) || null : null; } -// One provider is active per deployment; the argument is a test override. function resolveProvider(requestProvider) { return getProvider(requestProvider || process.env.EPP_PROVIDER_NAME); } -// The manifest carries only the secret's name; the value is read just-in-time and never logged. +// Secrets are resolved from Key Vault by name, cached briefly, and never logged. let keyVaultSecretClient = null; const secretCache = new Map(); -// The identity needs the Key Vault Secrets User role on the vault. +// The function's managed identity needs the Key Vault Secrets User role on the vault. function getKeyVaultSecretClient() { if (!keyVaultSecretClient) { const credential = process.env.AZURE_CLIENT_ID @@ -216,10 +217,71 @@ async function resolveSecretValue(keyVaultSecretName) { return secretValue; } -async function resolveProviderCredential(authConfiguration = {}, acquireProviderToken) { - if ((authConfiguration.mode || 'apiKey') === 'oauth2') { - // oauth2 is not wired end-to-end yet: with no injected acquireProviderToken it fails closed. - const bearerToken = typeof acquireProviderToken === 'function' ? await acquireProviderToken() : null; +// Acquire a provider-audienced app-only token from Entra; never forward the inbound token. +// Refresh before expiry so a cached token remains usable during the provider call. +const OAUTH_TOKEN_EXPIRY_SKEW_MILLISECONDS = 5 * 60 * 1000; +const DEFAULT_TOKEN_EXCHANGE_AUDIENCE = 'api://AzureADTokenExchange'; +let cachedProviderToken = null; + +function oauthModeEnabled(authConfiguration) { + const mode = (process.env.EPP_PROVIDER_AUTH_MODE || authConfiguration.mode || 'apiKey').toLowerCase(); + return mode === 'oauth2'; +} + +// EPP_PROVIDER_MI_CLIENT_ID selects secretless workload-identity federation; else fall back to a secret. +function buildFederatedCredential(tenantId, clientId) { + const managedIdentityClientId = process.env.EPP_PROVIDER_MI_CLIENT_ID; + if (!managedIdentityClientId) { + return null; + } + const managedIdentity = new ManagedIdentityCredential(managedIdentityClientId); + const audience = process.env.EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE || DEFAULT_TOKEN_EXCHANGE_AUDIENCE; + const exchangeScope = audience.endsWith('/.default') ? audience : `${audience}/.default`; + return new ClientAssertionCredential(tenantId, clientId, async () => { + const assertion = await managedIdentity.getToken(exchangeScope); + return assertion.token; + }); +} + +async function acquireProviderTokenFromEntra() { + const tenantId = process.env.EPP_PROVIDER_TENANT_ID; + const clientId = process.env.EPP_PROVIDER_CLIENT_ID; + const scope = process.env.EPP_PROVIDER_SCOPE; + if (!tenantId || !clientId || !scope) { + throw new Error('oauth2 requires EPP_PROVIDER_TENANT_ID, EPP_PROVIDER_CLIENT_ID and EPP_PROVIDER_SCOPE'); + } + const cacheKey = `${tenantId}|${clientId}|${scope}`; + if (cachedProviderToken + && cachedProviderToken.cacheKey === cacheKey + && cachedProviderToken.expiresAt - OAUTH_TOKEN_EXPIRY_SKEW_MILLISECONDS > Date.now()) { + return cachedProviderToken.value; + } + + let credential = buildFederatedCredential(tenantId, clientId); + if (!credential) { + const clientSecret = process.env.EPP_PROVIDER_CLIENT_SECRET + || (process.env.EPP_PROVIDER_CLIENT_SECRET_NAME ? await resolveSecretValue(process.env.EPP_PROVIDER_CLIENT_SECRET_NAME) : ''); + if (!clientSecret) { + throw new Error('oauth2 requires EPP_PROVIDER_MI_CLIENT_ID (managed identity) or EPP_PROVIDER_CLIENT_SECRET_NAME'); + } + credential = new ClientSecretCredential(tenantId, clientId, clientSecret); + } + + const accessToken = await credential.getToken(scope); + if (!accessToken || !accessToken.token) { + throw new Error('oauth2 token acquisition returned no token'); + } + cachedProviderToken = { + value: accessToken.token, + expiresAt: accessToken.expiresOnTimestamp || (Date.now() + 55 * 60 * 1000), + cacheKey, + }; + return cachedProviderToken.value; +} + +async function resolveProviderCredential(authConfiguration = {}) { + if (oauthModeEnabled(authConfiguration)) { + const bearerToken = await acquireProviderTokenFromEntra(); return { mode: 'oauth2', token: bearerToken }; } @@ -232,12 +294,14 @@ async function resolveProviderCredential(authConfiguration = {}, acquireProvider return { mode: 'apiKey', secret, identity }; } -// A recognized status wins; an unknown status is fail-closed; only a status-less response trusts HTTP. +// An HTTP failure must never become acceptance based on a success-looking body. function resolveOutcome(manifest, parsedResponse) { const responseMapping = manifest.responseMapping || {}; const providerStatusKey = parsedResponse.providerStatusName || parsedResponse.providerStatusCode; if (providerStatusKey) { - return responseMapping[providerStatusKey] || responseMapping.default || OUTCOME.FAIL; + const outcome = Object.hasOwn(responseMapping, providerStatusKey) + ? responseMapping[providerStatusKey] : (responseMapping.default || OUTCOME.FAIL); + return outcome === OUTCOME.CONTINUE && !parsedResponse.success ? OUTCOME.FAIL : outcome; } return parsedResponse.success ? OUTCOME.CONTINUE : (responseMapping.default || OUTCOME.FAIL); } @@ -245,22 +309,22 @@ function resolveOutcome(manifest, parsedResponse) { function outcomeToHttpStatus(outcome, providerHttpStatus) { switch (outcome) { case OUTCOME.CONTINUE: - return HTTP_STATUS.OK; + return 200; case OUTCOME.BLOCK: - return HTTP_STATUS.FORBIDDEN; + return 403; case OUTCOME.STEP_UP: - return HTTP_STATUS.CONFLICT; + return 409; case OUTCOME.FAIL: - if (providerHttpStatus === HTTP_STATUS.TOO_MANY_REQUESTS) return HTTP_STATUS.TOO_MANY_REQUESTS; - if (providerHttpStatus === HTTP_STATUS.UNAUTHORIZED || providerHttpStatus === HTTP_STATUS.FORBIDDEN) return HTTP_STATUS.UNAUTHORIZED; - if (providerHttpStatus >= 400 && providerHttpStatus < 500) return HTTP_STATUS.BAD_REQUEST; - return HTTP_STATUS.BAD_GATEWAY; + if (providerHttpStatus === 429) return 429; + if (providerHttpStatus === 401 || providerHttpStatus === 403) return 401; + if (providerHttpStatus >= 400 && providerHttpStatus < 500) return 400; + return 502; default: - return HTTP_STATUS.BAD_GATEWAY; + return 502; } } - +// Include response-body reads in the timeout. Never retry: the POST may already have delivered. async function fetchWithTimeout(providerRequest, timeoutMilliseconds) { const abortController = new AbortController(); let timedOut = false; @@ -270,43 +334,48 @@ async function fetchWithTimeout(providerRequest, timeoutMilliseconds) { }, timeoutMilliseconds); try { - return await fetch(providerRequest.url, { + const response = await fetch(providerRequest.url, { method: providerRequest.method || 'POST', headers: providerRequest.headers, body: providerRequest.body, signal: abortController.signal, }); - } catch (error) { - throw new Error(timedOut ? `endpoint timeout after ${timeoutMilliseconds}ms` : error.message); + const responseText = await response.text(); + return { response, responseText }; + } catch { + throw Object.assign(new Error('provider request failed'), { timedOut }); } finally { clearTimeout(timeoutTimer); } } const errorBody = (providerId, reason, requestId) => - ({ status: RESPONSE_STATUS.ERROR, provider: providerId, reason, requestId }); + ({ status: 'error', provider: providerId, reason, requestId }); const failBody = (providerId, channel, reason, dispatch, requestId) => - ({ status: RESPONSE_STATUS.FAILED, outcome: OUTCOME.FAIL, provider: providerId, channel, reason, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }); + ({ status: 'failed', outcome: OUTCOME.FAIL, provider: providerId, channel, reason, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }); async function sendViaProvider(providerEntry, dispatch, options) { - const { shutter, context, requestId } = options; - const writeLog = (logMessage) => context && context.log(logMessage); - + const { shutter, requestId } = options; const { manifest, adapter } = providerEntry; const providerId = manifest.id; - const channel = (dispatch.channel || DEFAULTS.CHANNEL).toLowerCase(); + const channel = typeof dispatch.channel === 'string' ? dispatch.channel.toLowerCase() + : (dispatch.channel ? null : 'sms'); + if (!['sms', 'voice'].includes(channel)) { + return { httpStatus: 400, body: errorBody(providerId, 'unsupported channel', requestId) }; + } - if (!DEFAULTS.CHANNELS.includes(channel)) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} not supported`); - return { httpStatus: HTTP_STATUS.BAD_REQUEST, body: errorBody(providerId, `channel '${channel}' not supported`, requestId) }; + if (shutter) { + return { + httpStatus: 200, + body: { status: 'accepted', shutterProcessed: true, provider: providerId, channel, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }, + }; } - // Fail closed (502) if the credential is missing — this is our credential, not the caller's token. let credential = null; try { - credential = await resolveProviderCredential(manifest.auth, options.acquireProviderToken); - } catch (error) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} credential error=${error.message}`); + credential = await resolveProviderCredential(manifest.auth); + } catch { + // SDK errors can contain credentials and request bodies; report only the category below. } const identityRequired = credential && credential.mode === 'apiKey' && !!manifest.auth.identityKeyVaultSecretName; const credentialUnavailable = !credential @@ -314,14 +383,12 @@ async function sendViaProvider(providerEntry, dispatch, options) { || (credential.mode === 'apiKey' && !credential.secret) || (identityRequired && !credential.identity); if (credentialUnavailable) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} provider credential unavailable`); - return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; + return { httpStatus: 502, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; } const endpointBaseUrl = process.env.EPP_PROVIDER_ENDPOINT; - if (!endpointBaseUrl) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} provider=${providerId} channel=${channel} endpoint not configured`); - return { httpStatus: HTTP_STATUS.BAD_GATEWAY, body: failBody(providerId, channel, 'provider endpoint not configured', dispatch, requestId) }; + if (!isValidProviderEndpoint(endpointBaseUrl)) { + return { httpStatus: 502, body: failBody(providerId, channel, 'provider endpoint must be an absolute HTTPS URL', dispatch, requestId) }; } const providerRequest = adapter.buildRequest({ @@ -331,34 +398,26 @@ async function sendViaProvider(providerEntry, dispatch, options) { credential, env: process.env, }); - - writeLog(`[DISPATCH] requestId=${requestId} provider=${providerId} channel=${channel} correlationId=${dispatch.correlationId} shutter=${!!shutter}`); - - if (shutter) { - writeLog(`[SHUTTER] requestId=${requestId} provider=${providerId} channel=${channel} processed but NOT sending`); - return { - httpStatus: HTTP_STATUS.OK, - body: { status: RESPONSE_STATUS.ACCEPTED, shutterProcessed: true, provider: providerId, channel, correlationId: dispatch.correlationId, messageId: dispatch.messageId, requestId }, - }; + if (!isValidProviderEndpoint(providerRequest.url)) { + return { httpStatus: 502, body: failBody(providerId, channel, 'provider request URL must be absolute HTTPS', dispatch, requestId) }; } - const timeoutMilliseconds = Number(process.env.EPP_PROVIDER_TIMEOUT_MS) || DEFAULTS.ENDPOINT_TIMEOUT_MILLISECONDS; - let providerResponse; + const timeoutMilliseconds = normalizeProviderTimeoutMilliseconds(process.env.EPP_PROVIDER_TIMEOUT_MS); + let providerResult; try { - providerResponse = await fetchWithTimeout(providerRequest, timeoutMilliseconds); + providerResult = await fetchWithTimeout(providerRequest, timeoutMilliseconds); } catch (error) { - const isTimeout = typeof error.message === 'string' && error.message.startsWith('endpoint timeout'); - const httpStatus = isTimeout ? HTTP_STATUS.GATEWAY_TIMEOUT : HTTP_STATUS.BAD_GATEWAY; - writeLog(`[${isTimeout ? 'DISPATCH_TIMEOUT' : 'DISPATCH_ERROR'}] requestId=${requestId} provider=${providerId} channel=${channel} reason=${error.message}`); - return { httpStatus, body: failBody(providerId, channel, error.message, dispatch, requestId) }; + const isTimeout = error.timedOut === true; + const httpStatus = isTimeout ? 504 : 502; + const reason = isTimeout ? 'provider timeout' : 'provider request failed'; + return { httpStatus, body: failBody(providerId, channel, reason, dispatch, requestId) }; } - const responseText = await providerResponse.text(); + const { response: providerResponse, responseText } = providerResult; let responseJson; try { responseJson = JSON.parse(responseText); } catch { - // Keep a non-JSON body raw so the adapter's parseResponse still runs. responseJson = { raw: responseText }; } @@ -370,12 +429,10 @@ async function sendViaProvider(providerEntry, dispatch, options) { const outcome = resolveOutcome(manifest, parsedResponse); const httpStatus = outcomeToHttpStatus(outcome, parsedResponse.providerHttpStatus); - writeLog(`[DISPATCH_RESULT] requestId=${requestId} provider=${providerId} channel=${channel} outcome=${outcome} providerStatus=${parsedResponse.providerStatusName || parsedResponse.providerStatusCode || 'n/a'} httpStatus=${httpStatus} correlationId=${dispatch.correlationId}`); - return { httpStatus, body: { - status: outcome === OUTCOME.CONTINUE ? RESPONSE_STATUS.ACCEPTED : RESPONSE_STATUS.FAILED, + status: outcome === OUTCOME.CONTINUE ? 'accepted' : 'failed', outcome, provider: providerId, channel, @@ -391,26 +448,31 @@ async function sendViaProvider(providerEntry, dispatch, options) { async function dispatchOtp(dispatch, options) { const { requestProvider, context, requestId } = options; - const writeLog = (logMessage) => context && context.log(logMessage); - - const providerEntry = resolveProvider(requestProvider); - if (!providerEntry) { - writeLog(`[DISPATCH_ERROR] requestId=${requestId} unknown provider=${requestProvider || 'n/a'}`); - return { - httpStatus: HTTP_STATUS.BAD_REQUEST, - body: { status: RESPONSE_STATUS.ERROR, reason: 'unknown provider', requestId }, - }; + try { + const providerEntry = resolveProvider(requestProvider); + if (!providerEntry) { + return { + httpStatus: 400, + body: { status: 'error', reason: 'unknown provider', requestId }, + }; + } + return await sendViaProvider(providerEntry, dispatch, options); + } finally { + if (context) context.log(`[DISPATCH] requestId=${safeTraceId(requestId)}`); } - return sendViaProvider(providerEntry, dispatch, options); } module.exports = { + safeTraceId, parseEnvelope, decryptDeliveryContext, contextToDispatch, MODE, + OUTCOME, dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus, + isValidProviderEndpoint, + normalizeProviderTimeoutMilliseconds, }; diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index 2a79465..1f2ed21 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -4,8 +4,7 @@ 'use strict'; -// Soprano Connect (MEMS): POST {base}/messages/{sms|voice}, base https:///cgpapi. -// Auth: X-MEMS-API-ID + X-MEMS-API-Key, or a Bearer JWT. Verified live (HTTP 201, ENROUTE). +// Omnimsg handles SMS and voice; authentication can use API ID/key or a provider JWT. const manifest = { id: 'soprano', @@ -21,6 +20,8 @@ const manifest = { SENT: 'Continue', DELIVERED: 'Continue', QUEUED: 'Continue', + // Accepted (HTTP 201) but stopped by an account/destination filter — nothing was delivered. + FILTERED: 'Fail', FAILED: 'Fail', REJECTED: 'Fail', BLOCKED: 'Block', @@ -29,9 +30,6 @@ const manifest = { }; function buildRequest({ channel, endpoint, dispatch, credential, env }) { - const base = endpoint; - const messageType = channel === 'voice' ? 'voice' : 'sms'; - const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }; if (credential.mode === 'oauth2') { headers.Authorization = `Bearer ${credential.token}`; @@ -40,37 +38,19 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { headers['X-MEMS-API-Key'] = credential.secret; } + let destination = String(dispatch.destination || ''); + while (destination.startsWith('+')) destination = destination.slice(1); + const body = { - messageType, - destination: dispatch.destination, text: dispatch.message, - clientReference: dispatch.correlationId || dispatch.messageId, + destination, + messageTypes: [channel === 'voice' ? 'voice' : 'sms'], + correlationId: dispatch.correlationId || dispatch.messageId, + // Soprano processes the request but delivers nothing — connectivity/credential testing. + shutterMode: String(env.SOPRANO_SHUTTER_MODE || '').toLowerCase() === 'true', }; - // Soprano wants a provisioned (numeric) source endpoint; a non-numeric name goes as free-text source. - const account = env.EPP_PROVIDER_ACCOUNT_NAME; - if (account && /^\d+$/.test(account)) { - body.endpoints = [{ type: Number(env.SOPRANO_SOURCE_TYPE || 1), id: Number(account) }]; - } else if (account) { - body.source = account; - } - // `language` must be a full voice code (e.g. en-US), not a bare `en`. - if (messageType === 'voice') { - const voiceLanguage = env.SOPRANO_VOICE_LANGUAGE - || (dispatch.locale && dispatch.locale.includes('-') ? dispatch.locale : 'en-US'); - delete body.text; - body.voice = { - text2voice: { - beforePasswordText: dispatch.message || '', - password: '', - afterPasswordText: '', - language: voiceLanguage, - gender: Number(env.SOPRANO_VOICE_GENDER || 1), - loop: 1, - }, - }; - } - return { url: `${base}/messages/${messageType}`, method: 'POST', headers, body: JSON.stringify(body) }; + return { url: `${endpoint}/messages/omnimsg`, method: 'POST', headers, body: JSON.stringify(body) }; } function parseResponse({ httpStatus, ok, json }) { diff --git a/javascript/src/functions/security.js b/javascript/src/functions/security.js index 9f2d6df..f285d24 100644 --- a/javascript/src/functions/security.js +++ b/javascript/src/functions/security.js @@ -26,8 +26,13 @@ function isExpectedCaller(payload, expectedClientId) { return (payload.azp || payload.appid) === expectedClientId; } -async function validateToken(request, context, requestId) { - if (String(process.env.EPP_REQUIRE_AUTH || 'false').toLowerCase() !== 'true') { +async function validateToken(request) { + const requireAuth = String(process.env.EPP_REQUIRE_AUTH || 'false').toLowerCase() === 'true'; + const runningInAzure = !!(process.env.WEBSITE_INSTANCE_ID || process.env.WEBSITE_HOSTNAME); + if (!requireAuth && runningInAzure) { + return { ok: false, reason: 'EPP_REQUIRE_AUTH must be true in Azure' }; + } + if (!requireAuth) { return { ok: true, skipped: true }; } @@ -59,12 +64,10 @@ async function validateToken(request, context, requestId) { }); if (!isExpectedCaller(payload, process.env.EPP_EXPECTED_CLIENT_ID)) { - context.log(`[AUTH_FAIL] requestId=${requestId} reason=unexpected caller appid=${payload.azp || payload.appid || 'none'}`); return { ok: false, reason: 'unexpected caller' }; } return { ok: true }; - } catch (error) { - context.log(`[AUTH_FAIL] requestId=${requestId} reason=${error.message}`); + } catch { return { ok: false, reason: 'token validation failed' }; } } diff --git a/javascript/test/auth.test.js b/javascript/test/auth.test.js index b5e80c2..dab3a13 100644 --- a/javascript/test/auth.test.js +++ b/javascript/test/auth.test.js @@ -1,62 +1,92 @@ 'use strict'; -const { test, afterEach } = require('node:test'); +const { test, beforeEach } = require('node:test'); const assert = require('node:assert'); -const { validateToken, isExpectedCaller } = require('../src/functions/security'); +const crypto = require('crypto'); +const Module = require('module'); +const jose = require('jose'); +const { validateToken } = require('../src/functions/security'); -const ctx = { log() {} }; -const reqWith = (headers = {}) => ({ headers: { get: (k) => headers[k.toLowerCase()] || null } }); +const hostile = '+15559876543|918273|PRIVATE-TOKEN-SENTINEL'; +const validate = (jwt) => validateToken({ headers: { get: () => jwt ? `Bearer ${jwt}` : null } }); +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +const issuer = 'https://login.microsoftonline.com/local-test-tenant/v2.0'; -afterEach(() => { +beforeEach(() => { delete process.env.EPP_REQUIRE_AUTH; delete process.env.EPP_EXPECTED_AUDIENCE; delete process.env.EPP_TENANT_ID; delete process.env.EPP_EXPECTED_CLIENT_ID; + delete process.env.EPP_EXPECTED_ISSUER; + delete process.env.WEBSITE_INSTANCE_ID; + delete process.env.WEBSITE_HOSTNAME; }); -test('skips validation when REQUIRE_AUTH is not true', async () => { - const r = await validateToken(reqWith(), ctx, 'r'); - assert.equal(r.ok, true); - assert.equal(r.skipped, true); +test('auth opt-out is local only; Azure fails closed', async () => { + assert.deepEqual(await validate(), { ok: true, skipped: true }); + process.env.WEBSITE_INSTANCE_ID = 'instance'; + assert.deepEqual(await validate(), { ok: false, reason: 'EPP_REQUIRE_AUTH must be true in Azure' }); }); -test('fails when EPP_REQUIRE_AUTH=true but audience/tenant are missing', async () => { +test('required auth rejects missing configuration, missing bearer and malformed tokens', async (t) => { process.env.EPP_REQUIRE_AUTH = 'true'; - const r = await validateToken(reqWith(), ctx, 'r'); - assert.equal(r.ok, false); - assert.match(r.reason, /EPP_EXPECTED_AUDIENCE|EPP_TENANT_ID/); + assert.deepEqual(await validate(), { + ok: false, reason: 'EPP_REQUIRE_AUTH is set but EPP_EXPECTED_AUDIENCE / EPP_TENANT_ID are missing', + }); + configureLocalJwt(t); + assert.deepEqual(await validate(), { ok: false, reason: 'missing bearer token' }); + assert.deepEqual(await validate(hostile), { ok: false, reason: 'token validation failed' }); }); -test('fails when the bearer token is missing', async () => { +// Replace only remote key discovery; jose still verifies the real signature and claims. +function configureLocalJwt(t) { process.env.EPP_REQUIRE_AUTH = 'true'; - process.env.EPP_EXPECTED_AUDIENCE = 'aud'; - process.env.EPP_TENANT_ID = 'tid'; - const r = await validateToken(reqWith(), ctx, 'r'); - assert.equal(r.ok, false); - assert.equal(r.reason, 'missing bearer token'); + process.env.EPP_EXPECTED_AUDIENCE = 'local-audience'; + process.env.EPP_TENANT_ID = 'local-test-tenant'; + process.env.EPP_EXPECTED_CLIENT_ID = 'expected-app'; + const originalLoad = Module._load; + t.mock.method(Module, '_load', function (name, ...args) { + return name === 'jose' ? { ...jose, createRemoteJWKSet: () => publicKey } + : originalLoad.call(this, name, ...args); + }); +} + +function sign(claims = {}, alg = 'RS256', key = privateKey) { + return new jose.SignJWT({ azp: 'expected-app', ...claims }) + .setProtectedHeader({ alg, kid: hostile }) + .setIssuer(claims.iss || issuer).setAudience(claims.aud || 'local-audience') + .setExpirationTime(claims.exp ?? '5m').sign(key); +} + +test('valid signed v1/v2 tokens pass with a local JWKS key', async (t) => { + configureLocalJwt(t); + assert.deepEqual(await validate(await sign()), { ok: true }); + const v1 = await sign({ azp: undefined, appid: 'expected-app', iss: 'https://sts.windows.net/local-test-tenant/' }); + assert.deepEqual(await validate(v1), { ok: true }); }); -test('fails (generic reason) on an invalid token', async () => { - process.env.EPP_REQUIRE_AUTH = 'true'; - process.env.EPP_EXPECTED_AUDIENCE = 'aud'; - process.env.EPP_TENANT_ID = 'tid'; - const r = await validateToken(reqWith({ authorization: 'Bearer not-a-jwt' }), ctx, 'r'); - assert.equal(r.ok, false); - assert.equal(r.reason, 'token validation failed'); +test('signed tokens enforce issuer, audience, expiry, not-before and an explicit issuer pin', async (t) => { + configureLocalJwt(t); + for (const claims of [{ aud: hostile }, { iss: hostile }, { exp: 1 }, { nbf: Math.floor(Date.now() / 1000) + 3600 }]) { + assert.deepEqual(await validate(await sign(claims)), { ok: false, reason: 'token validation failed' }); + } + process.env.EPP_EXPECTED_ISSUER = 'https://sts.windows.net/local-test-tenant/'; + assert.deepEqual(await validate(await sign()), { ok: false, reason: 'token validation failed' }); }); -// Easy Auth normally rejects the wrong caller at the platform; these cover the standalone path. -for (const [callerAppId, expected, allowed] of [ - ['anything', '', true], // unpinned client id accepts any caller - ['expected-app', 'expected-app', true], - ['some-other-app', 'expected-app', false], - [undefined, 'expected-app', false], // token carrying no caller claim -]) { - test(`caller check: appid=${callerAppId} expected=${expected || '(unpinned)'} -> ${allowed}`, () => { - assert.equal(isExpectedCaller({ azp: callerAppId }, expected), allowed); - }); -} +test('real JWT verification rejects the wrong signing key and non-RS256 algorithms', async (t) => { + configureLocalJwt(t); + const wrongKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + assert.deepEqual(await validate(await sign({}, 'RS256', wrongKey)), { ok: false, reason: 'token validation failed' }); + const hmac = await sign({}, 'HS256', Buffer.from('local-test-key-for-HS256-rejection')); + assert.deepEqual(await validate(hmac), { ok: false, reason: 'token validation failed' }); +}); -test('caller check: accepts the v1 appid claim', () => { - assert.equal(isExpectedCaller({ appid: 'expected-app' }, 'expected-app'), true); +test('signed callers must match the pin; an unpinned caller is allowed', async (t) => { + configureLocalJwt(t); + const unexpected = await sign({ azp: hostile }); + assert.deepEqual(await validate(unexpected), { ok: false, reason: 'unexpected caller' }); + assert.deepEqual(await validate(await sign({ azp: undefined })), { ok: false, reason: 'unexpected caller' }); + delete process.env.EPP_EXPECTED_CLIENT_ID; + assert.deepEqual(await validate(unexpected), { ok: true }); }); diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index fff3223..44bdce3 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -1,18 +1,18 @@ 'use strict'; -// Integration tests for the dispatch pipeline with a mocked provider fetch and a mocked Key Vault. - const { test, beforeEach, mock } = require('node:test'); const assert = require('node:assert'); +const phone = '+15559876543'; +const code = '918273'; +const token = 'PRIVATE-TOKEN-SENTINEL'; +const hostile = `${phone}|${code}|${token}`; -// Non-secret provider config (app settings, not secrets) — set before requiring the modules. process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; process.env.SINCH_SERVICE_PLAN_ID = 'sp'; process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; -// Provider secrets come from Key Vault via managed identity in production; mock getSecret here. const providerSecrets = { - 'infobip-api-key': 'ib', + 'infobip-api-key': token, 'telesign-api-key': 'ts', 'telesign-customer-id': 'cust', 'sinch-api-token': 'st', @@ -21,173 +21,209 @@ const providerSecrets = { }; const { SecretClient } = require('@azure/keyvault-secrets'); mock.method(SecretClient.prototype, 'getSecret', async (name) => ({ value: providerSecrets[name] })); +const { ClientSecretCredential } = require('@azure/identity'); -const { dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus } = require('../src/functions/dispatch'); +const { + dispatchOtp, + getProvider, + normalizeProviderTimeoutMilliseconds, + outcomeToHttpStatus, + resolveOutcome, +} = require('../src/functions/dispatch'); let resp; let sent; global.fetch = async (url, opts) => { sent = { url, opts }; - if (resp === 'THROW') throw new Error('neterr'); - if (resp === 'TIMEOUT') throw new Error('endpoint timeout after 1500ms'); return { ok: resp.ok, status: resp.status, text: async () => JSON.stringify(resp.body) }; }; -const ctx = { log() {} }; -let n = 0; -const uniqueDest = () => '+1555' + String(1000000 + n++).slice(-7); -const disp = (o = {}) => ({ destination: uniqueDest(), message: 'Your code is 918273', channel: 'sms', messageId: 'm', correlationId: 'c' + Math.random(), ...o }); +const ctx = { log: (...args) => { + const text = JSON.stringify(args); + for (const secret of [phone, code, token]) assert.ok(!text.includes(secret), 'engine log leaked a sensitive value'); +} }; +const send = (provider = 'infobip', channel = 'sms') => dispatchOtp({ + destination: phone, message: `Your code is ${code}`, channel, messageId: 'm', correlationId: 'c', +}, { requestProvider: provider, context: ctx, requestId: 'r' }); beforeEach(() => { + sent = undefined; + process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; + delete process.env.EPP_PROVIDER_AUTH_MODE; + delete process.env.EPP_PROVIDER_MI_CLIENT_ID; + delete process.env.EPP_PROVIDER_TIMEOUT_MS; + delete process.env.SINCH_VOICE_ENDPOINT; resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'DELIVERED' } }] } }; }); -// A "success" response body shaped the way each provider's parseResponse expects, so each yields a -// status that maps to Continue (unknown statuses now fail closed — see resolveOutcome). -const successBody = { - infobip: { messages: [{ status: { name: 'DELIVERED' } }] }, - telesign: { status: { code: 290 } }, - sinch: { id: 'batch-1' }, - soprano: { status: 'DELIVERED' }, -}; - -for (const prov of ['infobip', 'telesign', 'sinch', 'soprano']) { - for (const ch of ['sms', 'voice']) { - test(`${prov}/${ch}: 200, message sent in body, https, provider auth scheme`, async () => { - resp = { ok: true, status: 200, body: successBody[prov] }; - const r = await dispatchOtp(disp({ channel: ch }), { requestProvider: prov, context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 200); - assert.match(sent.url, /^https:\/\//); - assert.ok(sent.opts.body.includes('918273'), 'rendered message missing from body'); - const providerAuth = sent.opts.headers.Authorization || sent.opts.headers['X-MEMS-API-Key']; - assert.ok(providerAuth, 'provider auth header missing'); - if (sent.opts.headers.Authorization) { - assert.match(sent.opts.headers.Authorization, /Bearer|App|Basic/); - } - }); - } -} +test('Infobip SMS sends JSON with App API-key auth', async () => { + assert.equal((await send()).httpStatus, 200); + assert.equal(sent.opts.headers.Authorization, `App ${token}`); + assert.ok(sent.url.endsWith('/sms/3/messages')); + assert.equal(JSON.parse(sent.opts.body).messages[0].content.text, `Your code is ${code}`); +}); -// Outcome + HTTP mapping is pure, so it is asserted directly here instead of once per case through -// the whole dispatch pipeline (mirrors the .NET and Python contract tests). -test('outcome mapping and HTTP status', () => { - const infobip = getProvider('infobip').manifest; - const soprano = getProvider('soprano').manifest; - const telesign = getProvider('telesign').manifest; +test('Telesign voice sends a form with Basic customer/key auth', async () => { + resp.body = { status: { code: 100 } }; + assert.equal((await send('telesign', 'voice')).httpStatus, 200); + assert.equal(sent.opts.headers.Authorization, `Basic ${Buffer.from('cust:ts').toString('base64')}`); + assert.ok(sent.url.endsWith('/v1/voice')); + const form = new URLSearchParams(sent.opts.body); + assert.equal(form.get('phone_number'), phone); + assert.equal(form.get('message'), `Your code is ${code}`); +}); - assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: 'DELIVERED' }), 'Continue'); - assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: 'REJECTED' }), 'Fail'); - assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: 'WATWAT' }), 'Fail'); - assert.equal(resolveOutcome(soprano, { success: true, providerStatusName: 'BLOCKED' }), 'Block'); - assert.equal(resolveOutcome(telesign, { success: true, providerStatusCode: '100' }), 'Continue'); +test('Sinch SMS uses its API token as Bearer auth', async () => { + resp.body = { id: 'batch-1' }; + assert.equal((await send('sinch')).httpStatus, 200); + assert.equal(sent.opts.headers.Authorization, 'Bearer st'); + assert.ok(sent.url.endsWith('/xms/v1/sp/batches')); + assert.equal(JSON.parse(sent.opts.body).body, `Your code is ${code}`); +}); - assert.equal(outcomeToHttpStatus('Continue', 200), 200); - assert.equal(outcomeToHttpStatus('Block', 200), 403); - assert.equal(outcomeToHttpStatus('StepUp', 200), 409); - assert.equal(outcomeToHttpStatus('Fail', 429), 429); - assert.equal(outcomeToHttpStatus('Fail', 403), 401); - assert.equal(outcomeToHttpStatus('Fail', 422), 400); - assert.equal(outcomeToHttpStatus('Fail', 500), 502); +test('Soprano omnimsg sends SMS and voice with API ID/key headers', async () => { + resp.body = { status: 'DELIVERED' }; + for (const channel of ['sms', 'voice']) { + assert.equal((await send('soprano', channel)).httpStatus, 200); + assert.ok(sent.url.endsWith('/messages/omnimsg')); + assert.equal(sent.opts.headers['X-MEMS-API-ID'], 'sp-id'); + assert.equal(sent.opts.headers['X-MEMS-API-Key'], 'sp'); + assert.equal(sent.opts.headers.Authorization, undefined); + const body = JSON.parse(sent.opts.body); + assert.deepEqual(body.messageTypes, [channel]); + assert.equal(body.destination, phone.slice(1)); + assert.equal(body.text, `Your code is ${code}`); + } }); -test('endpoint timeout maps to 504', async () => { - resp = 'TIMEOUT'; - const r = await dispatchOtp(disp(), { requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 504); +test('a failed HTTP response cannot be accepted despite a success-looking body', async () => { + resp.ok = false; + resp.status = 429; + const r = await send(); + assert.equal(r.httpStatus, 429); assert.equal(r.body.outcome, 'Fail'); }); -test('network error (non-timeout) maps to 502', async () => { - resp = 'THROW'; - const r = await dispatchOtp(disp(), { requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 502); - assert.equal(r.body.outcome, 'Fail'); +test('unknown statuses fail closed; outcome and HTTP categories stay distinct', () => { + const infobip = getProvider('infobip').manifest; + const soprano = getProvider('soprano').manifest; + for (const status of ['UNKNOWN', 'constructor', '__proto__']) { + assert.equal(resolveOutcome(infobip, { success: true, providerStatusName: status }), 'Fail', status); + } + assert.equal(resolveOutcome({ responseMapping: { RISK: 'StepUp' } }, { success: false, providerStatusName: 'RISK' }), 'StepUp'); + assert.equal(resolveOutcome(soprano, { success: false, providerStatusName: 'BLOCKED' }), 'Block'); + assert.equal(outcomeToHttpStatus('Block', 200), 403); + assert.equal(outcomeToHttpStatus('StepUp', 200), 409); + for (const [status, expected] of [[401, 401], [403, 401], [422, 400], [429, 429], [500, 502]]) { + assert.equal(outcomeToHttpStatus('Fail', status), expected, String(status)); + } }); -test('unknown provider status fails closed even on HTTP 200 (§15)', async () => { - resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'WATWATWAT' } }] } }; - const r = await dispatchOtp(disp(), { requestProvider: 'infobip', context: ctx, requestId: 'r' }); - assert.equal(r.body.outcome, 'Fail'); - assert.equal(r.body.status, 'failed'); +test('missing base endpoint and insecure alternate voice endpoint fail before sending', async () => { + delete process.env.EPP_PROVIDER_ENDPOINT; + const missing = await send(); + assert.equal(missing.httpStatus, 502); + assert.equal(missing.body.reason, 'provider endpoint must be an absolute HTTPS URL'); + assert.equal(sent, undefined); + process.env.EPP_PROVIDER_ENDPOINT = 'https://api.sinch.com'; + process.env.SINCH_VOICE_ENDPOINT = 'http://localhost:8080'; + const insecure = await send('sinch', 'voice'); + assert.equal(insecure.httpStatus, 502); + assert.equal(insecure.body.reason, 'provider request URL must be absolute HTTPS'); + assert.equal(sent, undefined); }); -// The code and phone necessarily appear in the outbound provider request — that is the delivery. -test('the code and phone never reach the logs or the response body', async () => { - const logs = []; - resp = { ok: true, status: 200, body: { messages: [{ status: { name: 'DELIVERED' }, messageId: 'x' }] } }; - const r = await dispatchOtp( - { destination: '+15551234567', message: 'Your code is 918273', channel: 'sms', messageId: 'm', correlationId: 'c' }, - { requestProvider: 'infobip', context: { log: (m) => logs.push(String(m)) }, requestId: 'r' }, - ); - - assert.equal(r.httpStatus, 200); - assert.ok(sent.opts.body.includes('918273'), 'the rendered message IS sent to the provider'); - for (const line of logs) { - assert.ok(!line.includes('918273'), `code leaked in a log line: ${line}`); - assert.ok(!line.includes('5551234567'), `phone leaked in a log line: ${line}`); - } - const body = JSON.stringify(r.body); - assert.ok(!body.includes('918273'), 'code leaked in response body'); - assert.ok(!body.includes('5551234567'), 'phone leaked in response body'); +test('the bounded timeout really aborts a pending response body, without retrying', { timeout: 5000 }, async (t) => { + assert.equal(normalizeProviderTimeoutMilliseconds('invalid'), 1500); + assert.equal(normalizeProviderTimeoutMilliseconds('2000'), 2000); + assert.equal(normalizeProviderTimeoutMilliseconds('999999'), 2500); + process.env.EPP_PROVIDER_TIMEOUT_MS = '10'; + let signal; + const fetch = t.mock.method(global, 'fetch', async (url, opts) => { + signal = opts.signal; + return { ok: true, status: 200, text: () => new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error(hostile)), { once: true }); + }) }; + }); + const r = await send(); + assert.equal(r.httpStatus, 504); + assert.equal(r.body.outcome, 'Fail'); + assert.equal(r.body.reason, 'provider timeout'); + assert.equal(signal.aborted, true); + assert.equal(fetch.mock.callCount(), 1); }); -test('apiKey mode fails closed when the secret is missing (502)', async () => { - const manifest = getProvider('soprano').manifest; - const saved = JSON.parse(JSON.stringify(manifest.auth)); - manifest.auth = { mode: 'apiKey', keyVaultSecretName: '__missing_secret__' }; - try { - const r = await dispatchOtp(disp(), { requestProvider: 'soprano', context: ctx, requestId: 'r' }); +test('API-key auth requires both the secret and any provider identity', async (t) => { + const manifest = getProvider('telesign').manifest; + const auth = manifest.auth; + t.after(() => { manifest.auth = auth; }); + for (const missing of [{ keyVaultSecretName: '__missing_key__' }, { identityKeyVaultSecretName: '__missing_id__' }]) { + manifest.auth = { ...auth, ...missing }; + const r = await send('telesign'); assert.equal(r.httpStatus, 502); assert.equal(r.body.reason, 'provider credential unavailable'); - } finally { - manifest.auth = saved; + assert.equal(sent, undefined); } }); -test('shutter returns 200 without sending', async () => { - let calls = 0; - const orig = global.fetch; - global.fetch = async (...a) => { calls++; return orig(...a); }; - try { - const r = await dispatchOtp(disp(), { requestProvider: 'infobip', shutter: true, context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 200); - assert.equal(r.body.shutterProcessed, true); - assert.equal(calls, 0); - } finally { - global.fetch = orig; - } +test('unknown provider and channel fail generically without sending', async () => { + const r = await send(hostile); + assert.equal(r.httpStatus, 400); + assert.equal(r.body.reason, 'unknown provider'); + const badChannel = await send('infobip', hostile); + assert.equal(badChannel.httpStatus, 400); + assert.equal(badChannel.body.reason, 'unsupported channel'); + assert.equal(sent, undefined); }); -test('unknown provider is rejected (400)', async () => { - const r = await dispatchOtp(disp(), { requestProvider: 'nope', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 400); +test('OAuth override replaces API-key headers with a minted Bearer and requires a token', async (t) => { + process.env.EPP_PROVIDER_AUTH_MODE = 'oauth2'; + process.env.EPP_PROVIDER_TENANT_ID = 'tenant-a'; + process.env.EPP_PROVIDER_CLIENT_ID = 'client-a'; + process.env.EPP_PROVIDER_SCOPE = 'api://provider-a/.default'; + process.env.EPP_PROVIDER_CLIENT_SECRET = 'secret'; + t.after(() => { + delete process.env.EPP_PROVIDER_AUTH_MODE; + delete process.env.EPP_PROVIDER_TENANT_ID; + delete process.env.EPP_PROVIDER_CLIENT_ID; + delete process.env.EPP_PROVIDER_SCOPE; + delete process.env.EPP_PROVIDER_CLIENT_SECRET; + }); + let accessToken = { token, expiresOnTimestamp: Date.now() + 60000 }; + t.mock.method(ClientSecretCredential.prototype, 'getToken', async () => accessToken); + resp.body = { status: 'DELIVERED' }; + assert.equal((await send('soprano')).httpStatus, 200); + assert.equal(sent.opts.headers.Authorization, `Bearer ${token}`); + assert.equal(sent.opts.headers['X-MEMS-API-Key'], undefined); + assert.equal(sent.opts.headers['X-MEMS-API-ID'], undefined); + accessToken = null; + sent = undefined; + const noToken = await send('soprano'); + assert.equal(noToken.httpStatus, 502); + assert.equal(noToken.body.reason, 'provider credential unavailable'); + assert.equal(sent, undefined); }); -test('oauth2 mode uses a Bearer token and fails closed without one', async () => { - const manifest = getProvider('sinch').manifest; - const saved = JSON.parse(JSON.stringify(manifest.auth)); - manifest.auth.mode = 'oauth2'; - try { - await dispatchOtp(disp(), { requestProvider: 'sinch', context: ctx, requestId: 'r', acquireProviderToken: async () => 'TKN' }); - assert.equal(sent.opts.headers.Authorization, 'Bearer TKN'); - - const noToken = await dispatchOtp(disp(), { requestProvider: 'sinch', context: ctx, requestId: 'r' }); - assert.equal(noToken.httpStatus, 502); // credential unavailable → 502, not 401 - } finally { - manifest.auth = saved; - } +test('credential SDK exceptions never reach logs or failure reasons', async (t) => { + const manifest = getProvider('infobip').manifest; + const auth = manifest.auth; + t.after(() => { manifest.auth = auth; }); + manifest.auth = { mode: 'apiKey', keyVaultSecretName: '__sdk_exception__' }; + t.mock.method(SecretClient.prototype, 'getSecret', async () => { + throw new Error(hostile, { cause: new Error(hostile) }); + }); + const r = await send(); + assert.equal(r.httpStatus, 502); + assert.equal(r.body.reason, 'provider credential unavailable'); + assert.equal(sent, undefined); }); -test('apiKey provider that needs an identity fails closed when the identity secret is missing (502)', async () => { - const manifest = getProvider('telesign').manifest; - const saved = JSON.parse(JSON.stringify(manifest.auth)); - manifest.auth.identityKeyVaultSecretName = '__missing_identity__'; - try { - const r = await dispatchOtp(disp(), { requestProvider: 'telesign', context: ctx, requestId: 'r' }); - assert.equal(r.httpStatus, 502); - assert.equal(r.body.reason, 'provider credential unavailable'); - } finally { - manifest.auth = saved; - } +test('network errors map to 502 without leaking or impersonating timeouts', async (t) => { + t.mock.method(global, 'fetch', async () => { throw new Error(`endpoint timeout ${hostile}`); }); + const r = await send(); + assert.equal(r.httpStatus, 502); + assert.equal(r.body.outcome, 'Fail'); + assert.equal(r.body.reason, 'provider request failed'); + for (const secret of [phone, code, token]) assert.ok(!JSON.stringify(r.body).includes(secret)); }); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 6372200..dc75d82 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -1,27 +1,28 @@ 'use strict'; -// Tests for the SendOtp HTTP handler — the SAS → EPP envelope: validation, JWE decryption round-trip, -// the happy path (nonce echo), Evaluation mode, and auth rejection. Handlers are captured by stubbing -// @azure/functions; the JWE is encrypted here with a throwaway RSA key that the handler decrypts via -// EPP_DECRYPTION_KEY_PEM. - -const { test, mock } = require('node:test'); +const { test, mock, beforeEach } = require('node:test'); const assert = require('node:assert'); const crypto = require('crypto'); const Module = require('module'); const { CompactEncrypt } = require('jose'); +const { parseEnvelope, getProvider, decryptDeliveryContext } = require('../src/functions/dispatch'); + +const phone = '+15559876543'; +const code = '918273'; +const token = 'PRIVATE-TOKEN-SENTINEL'; +const hostile = `${phone}|${code}|${token}`; -// Throwaway RSA keypair: the handler decrypts with the private PEM from the environment. const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); -process.env.EPP_DECRYPTION_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); +const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }); +process.env.EPP_DECRYPTION_KEY_PEM = privatePem; process.env.KEY_VAULT_URL = 'https://test.vault.azure.net'; process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; process.env.EPP_PROVIDER_NAME = 'infobip'; const { SecretClient } = require('@azure/keyvault-secrets'); -mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'ib' })); +mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: token })); -// Capture the handlers SendOtp registers via app.http(...) by stubbing @azure/functions during require. +// Capture the handler without starting the Functions host. const handlers = {}; const originalLoad = Module._load; Module._load = function (request, parent, isMain) { @@ -33,10 +34,32 @@ Module._load = function (request, parent, isMain) { require('../src/functions/SendOtp'); Module._load = originalLoad; -// The handler answers before the provider call finishes, so tests await the send it kicked off. -const { whenDelivered } = require('../src/functions/SendOtp'); +async function invoke(request) { + const logs = []; + const log = (...args) => logs.push(args); + const response = await handlers.SendOtp(request, { log, warn: log, error: log, invocationId: hostile }); + if (response.status !== 200) assert.equal(response.jsonBody.nonce, undefined); + const text = JSON.stringify(logs); + for (const secret of [phone, code, token, privatePem, 'nonce-abc', delivery.message]) { + assert.ok(!text.includes(secret), 'sensitive value must not appear in any log argument'); + if (response.status !== 200) assert.ok(!JSON.stringify(response.jsonBody).includes(secret), 'failure reflected private input'); + } + return response; +} -const ctx = { log() {} }; +beforeEach(() => { + sent = undefined; + process.env.EPP_REQUIRE_AUTH = 'false'; + process.env.EPP_LOG_PLAINTEXT = 'true'; + process.env.EPP_PROVIDER_ENDPOINT = 'https://api.infobip.com'; + delete process.env.WEBSITE_INSTANCE_ID; + delete process.env.WEBSITE_HOSTNAME; + delete process.env.EPP_EXPECTED_CLIENT_ID; + delete process.env.EPP_PROVIDER_AUTH_MODE; + delete process.env.EPP_PROVIDER_TIMEOUT_MS; + delete process.env.EPP_EXPECTED_AUDIENCE; + delete process.env.EPP_TENANT_ID; +}); const makeReq = (body, headers = {}) => ({ method: 'POST', @@ -45,27 +68,18 @@ const makeReq = (body, headers = {}) => ({ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), }); -async function encryptContext(context, kid = 'test-key') { +async function encryptContext(context, kid = hostile) { return new CompactEncrypt(Buffer.from(JSON.stringify(context))) .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', kid }) .encrypt(publicKey); } -const sampleContext = () => ({ - nonce: 'nonce-abc', - phoneNumber: '+14255551234', - locale: 'en-US', - message: 'Your code is 1 2 3 4 5 6', -}); +const delivery = { nonce: 'nonce-abc', phoneNumber: phone, locale: 'en-US', message: `Your code is ${code}; reference 2026-09-08.` }; -async function makeEnvelope(overrides = {}, context = sampleContext()) { +async function makeEnvelope(overrides = {}, context = delivery) { return { type: 'microsoft.mfa.otpDeliver.v1', - tenantId: 'tenant-1', - correlationId: 'corr-1', - channel: 1, - mode: 1, - ttlSeconds: 60, + tenantId: 'tenant-1', correlationId: 'corr-1', channel: 1, mode: 1, ttlSeconds: 60, encryptedDeliveryContext: await encryptContext(context), ...overrides, }; @@ -81,83 +95,150 @@ global.fetch = async (url, opts) => { }; }; -test('SendOtp: invalid JSON body -> 400', async () => { - const r = await handlers.SendOtp(makeReq('{ not json'), ctx); - assert.equal(r.status, 400); - assert.equal(r.jsonBody.error, 'bad_request'); +test('SendOtp: parser failures never reflect private input, even in diagnostics', async () => { + const envelope = await makeEnvelope(); + for (const [body, reason] of [ + [`{ ${hostile}`, 'invalid JSON body'], [{ ...envelope, type: hostile }, 'unsupported type'], + [{ ...envelope, channel: hostile }, 'unsupported channel'], [{ ...envelope, mode: hostile }, 'unsupported mode'], + ]) { + const r = await invoke(makeReq(body)); + assert.deepEqual(r.jsonBody, { error: 'bad_request', reason, requestId: r.jsonBody.requestId }); + assert.equal(r.status, 400); + assert.equal(sent, undefined); + } }); -test('SendOtp: missing encryptedDeliveryContext -> 400', async () => { - const r = await handlers.SendOtp(makeReq({ type: 'v1', channel: 1, mode: 1 }), ctx); - assert.equal(r.status, 400); - assert.match(r.jsonBody.reason, /encryptedDeliveryContext/); +test('parser rejects malformed roots, missing ciphertext and coerced or inherited enums', async () => { + const envelope = await makeEnvelope(); + assert.equal(parseEnvelope(null).error, 'invalid envelope'); + assert.equal(parseEnvelope([]).error, 'invalid envelope'); + assert.equal(parseEnvelope({ ...envelope, encryptedDeliveryContext: null }).error, 'encryptedDeliveryContext is required'); + assert.equal(parseEnvelope({ ...envelope, channel: [1] }).error, 'unsupported channel'); + assert.equal(parseEnvelope({ ...envelope, mode: true }).error, 'unsupported mode'); + assert.equal(parseEnvelope({ ...envelope, mode: '__proto__' }).error, 'unsupported mode'); }); -test('SendOtp: unsupported channel -> 400', async () => { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ channel: 9 })), ctx); - assert.equal(r.status, 400); - assert.match(r.jsonBody.reason, /channel/); -}); - -test('SendOtp: unsupported mode -> 400', async () => { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ mode: 5 })), ctx); +test('SendOtp: incomplete context (no phoneNumber) -> 400', async () => { + const r = await invoke(makeReq(await makeEnvelope({}, { nonce: hostile, message: hostile }))); assert.equal(r.status, 400); - assert.match(r.jsonBody.reason, /mode/); + assert.equal(r.jsonBody.reason, 'incomplete delivery context'); + assert.equal(sent, undefined); }); -test('SendOtp: undecryptable context -> 400 decryption_failed', async () => { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ encryptedDeliveryContext: 'eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIn0.bad.bad.bad.bad' })), ctx); +test('SendOtp: Live with ttlSeconds <= 0 is refused and nothing is sent', async () => { + const r = await invoke(makeReq(await makeEnvelope({ ttlSeconds: 0 }))); assert.equal(r.status, 400); - assert.equal(r.jsonBody.error, 'decryption_failed'); + assert.equal(r.jsonBody.reason, 'passcode has expired'); + assert.equal(sent, undefined, 'an expired passcode must not reach the provider'); }); -test('SendOtp: incomplete context (no phoneNumber) -> 400', async () => { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({}, { nonce: 'n', message: 'm' })), ctx); - assert.equal(r.status, 400); - assert.match(r.jsonBody.reason, /incomplete/); +test('TTL accepts omission or a positive integer, not coercion or fractions', async () => { + const envelope = await makeEnvelope(); + assert.ok(parseEnvelope(envelope).envelope); + delete envelope.ttlSeconds; + assert.ok(parseEnvelope(envelope).envelope); + for (const ttlSeconds of [null, true, '60', 0.5]) { + assert.equal(parseEnvelope({ ...envelope, ttlSeconds }).error, 'ttlSeconds must be a positive integer'); + } }); -test('SendOtp: Live with ttlSeconds <= 0 still delivers, but warns', async () => { - const lines = []; - const warnCtx = { log: (m) => lines.push(String(m)), warn: (m) => lines.push(String(m)), error: () => {} }; - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ ttlSeconds: 0 })), warnCtx); - await whenDelivered(); +test('SendOtp: real JWE waits for the provider body before echoing nonce; plaintext flag cannot leak', { timeout: 5000 }, async (t) => { + let finishBody, bodyStarted; + const body = new Promise((resolve) => { finishBody = resolve; }); + const reading = new Promise((resolve) => { bodyStarted = resolve; }); + t.mock.method(global, 'fetch', async (url, opts) => { + sent = { url, opts }; + return { ok: true, status: 200, text: () => { bodyStarted(); return body; } }; + }); + const envelope = await makeEnvelope({ channel: 'SMS', mode: 'LIVE', correlationId: hostile }); + let completed = false; + const pending = invoke(makeReq(envelope, { authorization: `Bearer ${token}`, 'x-ms-client-request-id': hostile })) + .then((r) => { completed = true; return r; }); + await reading; + await new Promise(setImmediate); + const completedBeforeBody = completed; + finishBody(JSON.stringify({ messages: [{ status: { name: 'DELIVERED' } }] })); + const r = await pending; + assert.equal(completedBeforeBody, false, 'the HTTP handler must wait for delivery acceptance'); assert.equal(r.status, 200); - assert.ok(lines.some((l) => /has expired/.test(l)), 'expected an expiry warning'); + assert.equal(r.jsonBody.providerStatus, 'accepted'); + assert.equal(r.jsonBody.nonce, delivery.nonce); + assert.equal(r.jsonBody.correlationId, hostile); + assert.equal(new URL(sent.url).protocol, 'https:'); + const message = JSON.parse(sent.opts.body).messages[0]; + assert.equal(message.content.text, delivery.message); + assert.equal(message.destinations[0].messageId, hostile, 'wire IDs must not be hashed'); }); -test('SendOtp: valid Live envelope -> 200 accepted, nonce echoed, sent over https', async () => { - sent = undefined; - const r = await handlers.SendOtp(makeReq(await makeEnvelope()), ctx); - await whenDelivered(); +test('SendOtp: Evaluation echoes nonce without delivery or a configured endpoint', async () => { + delete process.env.EPP_PROVIDER_ENDPOINT; + const r = await invoke(makeReq(await makeEnvelope({ channel: 'VOICE', mode: 'Evaluation' }))); assert.equal(r.status, 200); - assert.equal(r.jsonBody.providerStatus, 'accepted'); - assert.equal(r.jsonBody.nonce, 'nonce-abc'); - assert.equal(r.jsonBody.correlationId, 'corr-1'); - assert.match(sent.url, /^https:\/\//); + assert.equal(r.jsonBody.nonce, delivery.nonce); + assert.equal(sent, undefined); }); -test('SendOtp: Evaluation mode -> 200 nonce echoed, nothing sent', async () => { - let calls = 0; - const original = global.fetch; - global.fetch = async (...a) => { calls++; return original(...a); }; - try { - const r = await handlers.SendOtp(makeReq(await makeEnvelope({ mode: 2 })), ctx); - await whenDelivered(); - assert.equal(r.status, 200); - assert.equal(r.jsonBody.nonce, 'nonce-abc'); - assert.equal(calls, 0); - } finally { - global.fetch = original; +test('SendOtp: failed provider reply or unknown status cannot echo nonce or private fields', async (t) => { + let httpStatus; + t.mock.method(global, 'fetch', async () => ({ ok: httpStatus === 200, status: httpStatus, + text: async () => JSON.stringify({ messages: [{ messageId: hostile, status: { name: hostile, description: hostile } }] }), + })); + const envelope = await makeEnvelope({}, { ...delivery, nonce: hostile }); + for (const [upstream, expected] of [[401, 401], [200, 502]]) { + httpStatus = upstream; + const r = await invoke(makeReq(envelope)); + assert.equal(r.status, expected); + assert.deepEqual(r.jsonBody, { error: 'provider_delivery_failed', correlationId: 'corr-1', requestId: r.jsonBody.requestId }); } }); -test('SendOtp: REQUIRE_AUTH enabled but misconfigured -> 401', async () => { +test('SendOtp: an adapter exception becomes a generic failure without nonce', async (t) => { + t.mock.method(getProvider('infobip').adapter, 'buildRequest', () => { + throw new Error(hostile, { cause: new Error(hostile) }); + }); + const r = await invoke(makeReq(await makeEnvelope())); + assert.equal(r.status, 500); + assert.deepEqual(r.jsonBody, { error: 'delivery_failed', correlationId: 'corr-1', requestId: r.jsonBody.requestId }); + assert.equal(sent, undefined); +}); + +test('SendOtp: bearer and header-caller rejection are generic and precede body parsing', async (t) => { process.env.EPP_REQUIRE_AUTH = 'true'; - try { - const r = await handlers.SendOtp(makeReq(await makeEnvelope(), { authorization: 'Bearer abc' }), ctx); - assert.equal(r.status, 401); - } finally { - delete process.env.EPP_REQUIRE_AUTH; - } + process.env.EPP_EXPECTED_AUDIENCE = 'aud'; + process.env.EPP_TENANT_ID = 'tid'; + const request = makeReq(hostile, { authorization: `Bearer ${hostile}` }); + const read = t.mock.method(request, 'text'); + const r = await invoke(request); + assert.equal(r.status, 401); + assert.equal(r.jsonBody.reason, 'token validation failed'); + process.env.EPP_EXPECTED_CLIENT_ID = 'expected-app'; + const principal = Buffer.from(JSON.stringify({ claims: [{ typ: 'appid', val: hostile }] })).toString('base64'); + request.headers = makeReq('', { 'x-ms-client-principal': principal }).headers; + const blocked = await invoke(request); + assert.equal(blocked.status, 403); + assert.deepEqual(blocked.jsonBody, { error: 'unexpected_caller' }); + assert.equal(read.mock.callCount(), 0); +}); + +test('SendOtp: tampered JWE authentication tag is rejected without logging the hostile kid', async () => { + const segments = (await encryptContext(delivery)).split('.'); + const tag = Buffer.from(segments[4], 'base64url'); + tag[0] ^= 1; + segments[4] = tag.toString('base64url'); + const r = await invoke(makeReq(await makeEnvelope({ encryptedDeliveryContext: segments.join('.') }))); + assert.equal(r.status, 400); + assert.equal(r.jsonBody.error, 'decryption_failed'); + assert.equal(sent, undefined); +}); + +test('JWE shape, size and pinned algorithms reject invalid input locally', async () => { + const decrypt = (value) => decryptDeliveryContext(value, { decryptionKeyPem: privatePem }); + await assert.rejects(decrypt('a.b..d.e'), { message: 'malformed JWE: expected five non-empty segments' }); + await assert.rejects(decrypt('a'.repeat(16385)), { message: 'delivery context exceeds size limit' }); + const wrongAlg = await new CompactEncrypt(Buffer.from(JSON.stringify(delivery))) + .setProtectedHeader({ alg: 'RSA-OAEP', enc: 'A256GCM' }).encrypt(publicKey); + const wrongEnc = await new CompactEncrypt(Buffer.from(JSON.stringify(delivery))) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A128GCM' }).encrypt(publicKey); + await assert.rejects(decrypt(wrongAlg)); + await assert.rejects(decrypt(wrongEnc)); }); diff --git a/python/README.md b/python/README.md index 45ec00d..852148c 100644 --- a/python/README.md +++ b/python/README.md @@ -13,11 +13,8 @@ python/ ├─ requirements.txt ├─ src/ │ ├─ dispatch.py # envelope parse → JWE decrypt → provider dispatch -│ ├─ registry.py # adapter registry + EPP_PROVIDER_NAME resolution │ ├─ providers/*.py # infobip, telesign, soprano, sinch (manifest + build/parse) │ ├─ secrets.py # Key Vault via managed identity (cached) -│ ├─ outcome.py # status → outcome → HTTP status -│ ├─ models.py # DispatchRequest + outcome constants │ └─ security.py # Entra JWT validation when EPP_REQUIRE_AUTH=true └─ tests/ # pytest conformance tests ``` @@ -43,3 +40,22 @@ The app's **managed identity** needs the **Key Vault Secrets User** role on the [`../docs/CONTRACT.md`](../docs/CONTRACT.md). Target: Azure Functions Python **v2** programming model (Python 3.11), Functions v4. + +## Privacy and tracing + +The handler emits one `[EPP]` summary with `requestId`, a log-safe `correlationId`, provider/channel/mode, +HTTP status/outcome, elapsed time, and booleans indicating nonce echo and evaluation processing. +GUIDs are normalized; other trace IDs use a labeled hash. Original wire IDs are preserved, +including the correlation-header fallback. + +No plaintext logging switch is supported. Even `EPP_LOG_PLAINTEXT=true` cannot enable logging of +phone numbers, messages/codes, nonce values, risk context, tokens, keys, tenant IDs, JWE headers, or +raw bodies. Engine failures log fixed categories, never exception text or provider diagnostics. +Keep SDK/HTTP body tracing disabled as well; these application traces do not sanitize third-party logs. + +Provider HTTP failures retain their mapped HTTP status and return a generic error, fixed outcome/status, +correlation ID, and generated request ID. Internal provider diagnostics must not be logged or forwarded +wholesale. +Success still returns `nonce`, `correlationId`, and `providerStatus: accepted`, including evaluation +mode. OAuth token acquisition and the Soprano `/messages/omnimsg` adapter are unchanged; the draft +design's future wire format is not implemented. diff --git a/python/function_app.py b/python/function_app.py index 4bc21af..0d974a1 100644 --- a/python/function_app.py +++ b/python/function_app.py @@ -1,25 +1,27 @@ -"""POST /api/SendOtp — the SAS → External Phone Provider delivery endpoint. Validates the caller, parses -the cleartext routing envelope, decrypts the JWE delivery context, dispatches to the provider, and -echoes the nonce to prove decryption. Every line is tagged [EPP] so one filter pulls a whole delivery. -""" +"""POST /api/SendOtp: validate, decrypt, dispatch, and echo the nonce with a PII-safe summary.""" import base64 import json import logging import os -import threading import time import uuid import azure.functions as func from src.dispatch import ( + CHANNEL_BY_CODE, + CONTINUE, + FAIL, MODE_EVALUATION, + OUTCOMES, + PROVIDER_IDS, DispatchEngine, ProviderRegistry, context_to_dispatch, decrypt_delivery_context, make_key_provider, parse_envelope, + safe_trace_id, ) from src.providers.infobip import InfobipProvider from src.providers.sinch import SinchProvider @@ -38,16 +40,8 @@ _key_provider = make_key_provider(os.environ) -def _json(status_code, body): - return func.HttpResponse(json.dumps(body), status_code=status_code, mimetype="application/json") - - -def _log(label, value): - logging.info("%s %-18s: %s", TAG, label, value) - - def _read_caller_app_id(req): - """Easy Auth has already validated the token; this records which identity arrived.""" + """Easy Auth has already validated the token; check its caller against the allowlist.""" encoded = req.headers.get("x-ms-client-principal") if not encoded: return None @@ -63,114 +57,93 @@ def _read_caller_app_id(req): @app.route(route="SendOtp", methods=["POST"], auth_level=func.AuthLevel.ANONYMOUS) def send_otp(req: func.HttpRequest) -> func.HttpResponse: - started = time.time() - request_id = uuid.uuid4().hex - client_request_id = req.headers.get("x-ms-client-request-id") or request_id - header_correlation_id = req.headers.get("x-ms-correlation-id") - log_plaintext = (os.environ.get("EPP_LOG_PLAINTEXT") or "").lower() == "true" - expected_key_id = os.environ.get("EPP_ENCRYPTION_KEY_ID") - expected_client_id = os.environ.get("EPP_EXPECTED_CLIENT_ID") - - logging.info("%s ======== delivery received ========", TAG) - _log("invocation", request_id) - - correlation_id = None + started = time.perf_counter() + request_id = str(uuid.uuid4()) + correlation_id = request_id + provider = channel = mode = "unknown" + status, outcome = 500, FAIL + nonce_echo = shutter_processed = False + + def reply(code, body): + nonlocal status, nonce_echo + response = func.HttpResponse(json.dumps(body), status_code=code, mimetype="application/json") + status, nonce_echo = code, code == 200 and "nonce" in body + return response + try: + client_request_id = req.headers.get("x-ms-client-request-id") or request_id + header_correlation_id = req.headers.get("x-ms-correlation-id") + correlation_id = header_correlation_id or request_id + configured_provider = (os.environ.get("EPP_PROVIDER_NAME") or "").lower() + provider = configured_provider if configured_provider in PROVIDER_IDS else "unknown" + expected_client_id = os.environ.get("EPP_EXPECTED_CLIENT_ID") caller_app_id = _read_caller_app_id(req) - _log("caller appid", caller_app_id or "none (Easy Auth off, or called directly)") - if caller_app_id and expected_client_id and caller_app_id != expected_client_id: - logging.error("%s caller %s is not %s. Easy Auth allowedApplications is not doing its job.", - TAG, caller_app_id, expected_client_id) - return _json(403, {"error": "unexpected_caller"}) + return reply(403, {"error": "unexpected_caller", "requestId": request_id}) - auth_ok, reason, _caller_object_id = validate_token(req.headers.get("Authorization")) + auth_ok, _reason, _caller_object_id = validate_token(req.headers.get("Authorization")) if not auth_ok: - logging.error("%s token rejected: %s", TAG, reason) - return _json(401, {"error": "unauthorized", "reason": reason, "requestId": request_id}) + return reply(401, {"error": "unauthorized", "reason": "token validation failed", + "requestId": request_id}) try: payload = req.get_json() except ValueError: - logging.error("%s body is not JSON", TAG) - return _json(400, {"error": "bad_request", "reason": "invalid JSON body", "requestId": request_id}) + return reply(400, {"error": "bad_request", "reason": "invalid JSON body", + "requestId": request_id}) envelope, error = parse_envelope(payload) if error: - logging.error("%s envelope rejected: %s", TAG, error) - return _json(400, {"error": "bad_request", "reason": error, "requestId": request_id}) - - _log("type", envelope["type"]) - _log("tenantId", envelope["tenant_id"]) - _log("correlationId", envelope["correlation_id"]) - _log("channel", envelope["channel"]) - _log("mode", envelope["mode"]) - _log("ttlSeconds", envelope["ttl_seconds"]) + return reply(400, {"error": "bad_request", "reason": error, + "requestId": request_id}) correlation_id = envelope["correlation_id"] or header_correlation_id or request_id - - # Surfaced rather than swallowed: the passcode expires before it can be used. - ttl_seconds = envelope["ttl_seconds"] - if isinstance(ttl_seconds, (int, float)) and not isinstance(ttl_seconds, bool) and ttl_seconds <= 0: - logging.warning("%s ttlSeconds is %s; the passcode has expired.", TAG, ttl_seconds) + channel = CHANNEL_BY_CODE[envelope["channel"]] + evaluation = envelope["mode"] == MODE_EVALUATION + mode = "evaluation" if evaluation else "live" try: - header, delivery = decrypt_delivery_context(envelope["encrypted_delivery_context"], _key_provider) - except Exception as err: - logging.error("%s decryption failed: %s", TAG, err) - return _json(400, {"error": "decryption_failed", "correlationId": correlation_id, "requestId": request_id}) - - kid = header.get("kid") - kid_matches = not expected_key_id or kid == expected_key_id - _log("kid", f"{kid}{'' if kid_matches else ' (DOES NOT match EPP_ENCRYPTION_KEY_ID)'}") - _log("alg / enc", f"{header.get('alg')} / {header.get('enc')}") - _log("decrypted", "OK") - _log("nonce", delivery.get("nonce")) - - if log_plaintext: - # DIAGNOSTICS ONLY — writes the phone number and passcode to the log. - _log("phoneNumber", delivery.get("phoneNumber")) - _log("extension", delivery.get("extension") or "(none)") - _log("locale", delivery.get("locale")) - _log("message", delivery.get("message")) - _log("riskContext", json.dumps(delivery["riskContext"]) if delivery.get("riskContext") else "(none)") - else: - logging.info("%s plaintext suppressed (EPP_LOG_PLAINTEXT=false)", TAG) + _header, delivery = decrypt_delivery_context(envelope["encrypted_delivery_context"], _key_provider) + except Exception: + return reply(400, {"error": "decryption_failed", "correlationId": correlation_id, + "requestId": request_id}) if not delivery.get("nonce") or not delivery.get("phoneNumber") or not delivery.get("message"): - logging.error("%s delivery context is incomplete (nonce/phoneNumber/message)", TAG) - return _json(400, {"error": "bad_request", "reason": "incomplete delivery context", + return reply(400, {"error": "bad_request", "reason": "incomplete delivery context", "correlationId": correlation_id, "requestId": request_id}) - evaluation = envelope["mode"] == MODE_EVALUATION dispatch = context_to_dispatch(delivery, envelope, client_request_id) - - # Microsoft allows 3.2 s for the whole call, so the provider is called after the response. - def _deliver(): - try: - status, body = _engine.dispatch(dispatch, None, evaluation, request_id, logging) - logging.info( - "%s provider result : httpStatus=%s outcome=%s providerStatus=%s providerMessageId=%s correlationId=%s", - TAG, status, body.get("outcome") or "n/a", body.get("providerStatus") or "n/a", - body.get("providerMessageId") or "n/a", correlation_id) - except Exception as delivery_error: - logging.error("%s provider delivery failed: %s", TAG, delivery_error) - - # daemon so a stalled provider call cannot hold up worker shutdown. - threading.Thread(target=_deliver, name="epp-delivery", daemon=True).start() - - # Echoing the nonce is the whole contract: a 2xx without it is treated as a failed delivery and - # Microsoft re-sends over its own telephony, so the user gets the code twice. - _log("responding", f"200, nonce echoed, {(time.time() - started) * 1000:.0f} ms") - logging.info("%s ======== done ========", TAG) - - return _json(200, { + dispatch.correlation_id = correlation_id + + provider_status, provider_body = _engine.dispatch(dispatch, None, evaluation, request_id, logging) + if type(provider_status) is not int or not 100 <= provider_status <= 599: + provider_status = 502 + if provider_status != 200: + candidate = provider_body.get("outcome") + outcome = candidate if candidate in OUTCOMES and candidate != CONTINUE else FAIL + return reply(provider_status, { + "error": "provider_delivery_failed", "status": "failed", "outcome": outcome, + "correlationId": correlation_id, "requestId": request_id, + }) + + # The nonce proves decryption on the wire; logs record only its presence. + response = reply(200, { "nonce": delivery["nonce"], "correlationId": correlation_id, "providerStatus": "accepted", }) - except Exception as error: - # Verbose on purpose: this endpoint exists to diagnose onboarding. - logging.error("%s FAILED after %.0f ms: %s", TAG, (time.time() - started) * 1000, error) - logging.info("%s ======== failed ========", TAG) - return _json(500, {"error": "delivery_failed", "detail": str(error), "correlationId": correlation_id}) + outcome = CONTINUE + shutter_processed = evaluation and provider_body.get("shutterProcessed") is True + return response + except Exception: + outcome = FAIL + return reply(500, {"error": "delivery_failed", "correlationId": correlation_id, "requestId": request_id}) + finally: + logging.info("%s %s", TAG, json.dumps({ + "requestId": request_id, + "correlationId": safe_trace_id(correlation_id), + "provider": provider, "channel": channel, "mode": mode, + "httpStatus": status, "outcome": outcome, + "nonceEcho": nonce_echo, "shutterProcessed": shutter_processed, + "elapsedMs": round((time.perf_counter() - started) * 1000), + })) diff --git a/python/src/dispatch.py b/python/src/dispatch.py index aca13cb..c6c0d57 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -1,23 +1,33 @@ -"""Delivery pipeline: parse the cleartext SAS envelope, decrypt the JWE that carries the PII, then -dispatch to the configured provider. Fail-closed — only a Continue outcome is "accepted".""" +"""Parse, decrypt, and dispatch OTP delivery; only a Continue outcome succeeds.""" import base64 +import hashlib import json import os -import re +import time +import uuid from dataclasses import dataclass +from urllib.parse import urlparse import requests +from azure.identity import ( + ClientAssertionCredential, + ClientSecretCredential, + ManagedIdentityCredential, +) from jwcrypto import jwe as jwe_module from jwcrypto import jwk DEFAULT_TIMEOUT_MS = 1500 +MAX_PROVIDER_TIMEOUT_MS = 2500 DEFAULT_CHANNELS = ["sms", "voice"] +ENVELOPE_TYPE = "microsoft.mfa.otpDeliver.v1" -# Outcomes (mirrors the other languages). CONTINUE = "Continue" FAIL = "Fail" BLOCK = "Block" STEP_UP = "StepUp" +OUTCOMES = (CONTINUE, FAIL, BLOCK, STEP_UP) +PROVIDER_IDS = ("infobip", "telesign", "sinch", "soprano") @dataclass @@ -30,13 +40,27 @@ class DispatchRequest: locale: str | None +def safe_trace_id(value): + """Normalize GUIDs or label a truncated SHA256 hash; never alter wire correlation.""" + if value is None: + return "none" + if isinstance(value, str): + try: + return str(uuid.UUID(value)) + except ValueError: + pass + else: + value = json.dumps(value, sort_keys=True, separators=(",", ":")) + return "sha256:" + hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()[:16] + + def resolve_outcome(manifest, parsed): - """A recognized status wins; an unknown status is fail-closed; only a status-less response - trusts the HTTP result.""" + """A success-looking body must never override an HTTP failure.""" mapping = manifest["response_mapping"] key = parsed.get("provider_status_name") or parsed.get("provider_status_code") if key: - return mapping.get(key) or mapping.get("default", FAIL) + outcome = mapping.get(key) or mapping.get("default", FAIL) + return FAIL if outcome == CONTINUE and not parsed.get("success") else outcome return CONTINUE if parsed.get("success") else mapping.get("default", FAIL) @@ -57,14 +81,32 @@ def to_http_status(outcome, provider_http_status): return 502 +def normalize_provider_timeout_ms(value): + try: + parsed = int(value) + except (TypeError, ValueError): + return DEFAULT_TIMEOUT_MS + if isinstance(value, bool) or parsed <= 0 or str(parsed) != str(value).strip(): + return DEFAULT_TIMEOUT_MS + return min(parsed, MAX_PROVIDER_TIMEOUT_MS) + + +def is_valid_provider_endpoint(value): + try: + parsed = urlparse(value) + return parsed.scheme == "https" and bool(parsed.netloc) + except (TypeError, ValueError): + return False + + class ProviderRegistry: - """One provider is active per deployment; request_provider is a test override.""" + """One provider is active per deployment; request_provider overrides EPP_PROVIDER_NAME when set.""" def __init__(self, adapters): self._by_id = {adapter.manifest["id"].lower(): adapter for adapter in adapters} def get(self, provider_id): - if not provider_id: + if not isinstance(provider_id, str) or not provider_id: return None return self._by_id.get(provider_id.lower()) @@ -72,7 +114,7 @@ def resolve(self, request_provider): return self.get(request_provider or os.environ.get("EPP_PROVIDER_NAME")) -# Channel: 1=Sms, 2=Voice. DeliveryMode: 1=Live, 2=Evaluation (do NOT deliver). +# Channel: 1=sms, 2=voice. Mode: 1=live (deliver), 2=evaluation (do not deliver). CHANNEL_BY_CODE = {1: "sms", 2: "voice"} CHANNEL_BY_NAME = {"sms": 1, "voice": 2} MODE_LIVE = 1 @@ -83,9 +125,7 @@ def resolve(self, request_provider): def _normalize_channel(channel): - if isinstance(channel, bool): - return None - if channel in CHANNEL_BY_CODE: + if type(channel) is int and channel in CHANNEL_BY_CODE: return channel if isinstance(channel, str): return CHANNEL_BY_NAME.get(channel.lower()) @@ -93,9 +133,7 @@ def _normalize_channel(channel): def _normalize_mode(mode): - if isinstance(mode, bool): - return None - if mode in (MODE_LIVE, MODE_EVALUATION): + if type(mode) is int and mode in (MODE_LIVE, MODE_EVALUATION): return mode if isinstance(mode, str): return MODE_BY_NAME.get(mode.lower()) @@ -106,22 +144,29 @@ def parse_envelope(payload): """Returns (envelope, None) or (None, error).""" if not isinstance(payload, dict): return None, "invalid envelope" + if payload.get("type") != ENVELOPE_TYPE: + return None, "unsupported type" encrypted = payload.get("encryptedDeliveryContext") if not isinstance(encrypted, str) or not encrypted: return None, "encryptedDeliveryContext is required" channel = _normalize_channel(payload.get("channel")) if channel is None: - return None, f"unsupported channel '{payload.get('channel')}'" + return None, "unsupported channel" mode = _normalize_mode(payload.get("mode")) if mode is None: - return None, f"unsupported mode '{payload.get('mode')}'" + return None, "unsupported mode" + ttl_seconds = payload.get("ttlSeconds") + if "ttlSeconds" in payload and type(ttl_seconds) is not int: + return None, "ttlSeconds must be a positive integer" + if ttl_seconds is not None and ttl_seconds <= 0: + return None, "passcode has expired" return { "type": payload.get("type"), "tenant_id": payload.get("tenantId"), "correlation_id": payload.get("correlationId"), "channel": channel, "mode": mode, - "ttl_seconds": payload.get("ttlSeconds"), + "ttl_seconds": ttl_seconds, "encrypted_delivery_context": encrypted, }, None @@ -150,13 +195,13 @@ def _assert_well_formed_jwe(compact_jwe): raise ValueError("malformed JWE: expected five non-empty segments") -# Imported once: a per-delivery RSA import would sit inside the response budget. +# Cache the imported key: re-importing RSA on every delivery would eat the response budget. _key_cache = {} def _normalize_pem(value): - """The setup script stores the key as base64 over the PEM so its newlines survive being carried as - an app setting, so accept either form.""" + """The key may arrive as a PEM or as base64 over the PEM (the setup script uses base64 so newlines + survive being stored as an app setting); accept either form.""" text = value if isinstance(value, str) else value.decode("utf-8") if "-----BEGIN" in text: return text @@ -179,23 +224,17 @@ def decrypt_delivery_context(compact_jwe, key_provider): _assert_well_formed_jwe(compact_jwe) header = read_protected_header(compact_jwe) key = _load_private_key(key_provider(header.get("kid"))) - # Pin alg/enc so a tampered header can't downgrade the crypto. + # Pin alg/enc so a tampered header cannot downgrade the encryption. token = jwe_module.JWE(algs=["RSA-OAEP-256", "A256GCM"]) token.deserialize(compact_jwe, key=key) return header, json.loads(token.payload.decode("utf-8")) -def _space_passcode_for_voice(message): - """Left alone, TTS reads 641895 as "six hundred forty-one thousand...", which no user can type.""" - return re.sub(r"\b\d{4,8}\b", lambda m: " ".join(m.group(0)), message or "", count=1) - - def context_to_dispatch(context, envelope, message_id): - """The message is pre-rendered and already contains the passcode, so there is no separate code.""" return DispatchRequest( destination=context.get("phoneNumber"), - message=(_space_passcode_for_voice(context.get("message")) - if CHANNEL_BY_CODE[envelope["channel"]] == "voice" else context.get("message")), + # The caller owns localization and voice digit spacing; preserve the rendered text. + message=context.get("message"), channel=CHANNEL_BY_CODE[envelope["channel"]], message_id=message_id, correlation_id=envelope["correlation_id"], @@ -203,6 +242,50 @@ def context_to_dispatch(context, envelope, message_id): ) +# Mint a provider-scoped app-only token; never forward the caller's inbound token. +OAUTH_TOKEN_EXPIRY_SKEW_SECONDS = 5 * 60 +_provider_token_cache = {} + + +def _build_oauth_credential(env, secrets, tenant_id, client_id): + """Managed-identity federation (selected by EPP_PROVIDER_MI_CLIENT_ID) keeps the cross-tenant call + secretless; otherwise use a client secret from Key Vault (or an env var for local runs).""" + mi_client_id = env.get("EPP_PROVIDER_MI_CLIENT_ID") + if mi_client_id: + managed_identity = ManagedIdentityCredential(client_id=mi_client_id) + audience = env.get("EPP_PROVIDER_TOKEN_EXCHANGE_AUDIENCE") or "api://AzureADTokenExchange" + exchange_scope = audience if audience.endswith("/.default") else f"{audience}/.default" + return ClientAssertionCredential( + tenant_id, + client_id, + lambda: managed_identity.get_token(exchange_scope).token, + ) + secret = env.get("EPP_PROVIDER_CLIENT_SECRET") or ( + secrets.resolve(env.get("EPP_PROVIDER_CLIENT_SECRET_NAME")) if env.get("EPP_PROVIDER_CLIENT_SECRET_NAME") else "" + ) + if not secret: + raise ValueError( + "oauth2 requires EPP_PROVIDER_MI_CLIENT_ID (managed identity) or EPP_PROVIDER_CLIENT_SECRET_NAME" + ) + return ClientSecretCredential(tenant_id, client_id, secret) + + +def acquire_provider_token(env, secrets): + tenant_id = env.get("EPP_PROVIDER_TENANT_ID") + client_id = env.get("EPP_PROVIDER_CLIENT_ID") + scope = env.get("EPP_PROVIDER_SCOPE") + if not (tenant_id and client_id and scope): + raise ValueError("oauth2 requires EPP_PROVIDER_TENANT_ID, EPP_PROVIDER_CLIENT_ID and EPP_PROVIDER_SCOPE") + cache_key = f"{tenant_id}|{client_id}|{scope}" + cached = _provider_token_cache.get(cache_key) + if cached and cached[1] - OAUTH_TOKEN_EXPIRY_SKEW_SECONDS > time.time(): + return cached[0] + credential = _build_oauth_credential(env, secrets, tenant_id, client_id) + access = credential.get_token(scope) + _provider_token_cache[cache_key] = (access.token, access.expires_on) + return access.token + + class DispatchEngine: def __init__(self, registry, secrets, env=None): self.registry = registry @@ -210,24 +293,32 @@ def __init__(self, registry, secrets, env=None): self.env = env if env is not None else os.environ def dispatch(self, dispatch, request_provider, shutter, request_id, log): + log_request_id = safe_trace_id(request_id) adapter = self.registry.resolve(request_provider) if adapter is None: - log.warning("[DISPATCH_ERROR] requestId=%s unknown provider=%s", request_id, request_provider or "n/a") + log.warning("[DISPATCH] requestId=%s provider=unknown", log_request_id) return 400, {"status": "error", "reason": "unknown provider", "requestId": request_id} manifest = adapter.manifest provider_id = manifest["id"] - channel = (dispatch.channel or "sms").lower() + provider_log_id = provider_id.lower() if isinstance(provider_id, str) else "unknown" + if provider_log_id not in PROVIDER_IDS: + provider_log_id = "unknown" + channel = dispatch.channel or "sms" + channel = channel.lower() if isinstance(channel, str) else "unknown" if channel not in DEFAULT_CHANNELS: - return 400, {"status": "error", "provider": provider_id, "reason": f"channel '{channel}' not supported", "requestId": request_id} + log.warning("[DISPATCH] requestId=%s provider=%s channel=unknown", log_request_id, provider_log_id) + return 400, {"status": "error", "provider": provider_id, "reason": "unsupported channel", "requestId": request_id} + + if shutter: + return 200, {"status": "accepted", "shutterProcessed": True, "provider": provider_id, "channel": channel, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} - # Credential (fail closed 502 if missing) — this is our credential, not the caller's token. credential = None try: credential = self._resolve_credential(manifest["auth"]) - except Exception as error: - log.error("[DISPATCH_ERROR] requestId=%s provider=%s credential error=%s", request_id, provider_id, error) + except Exception: + log.error("[DISPATCH] requestId=%s provider=%s channel=%s credential unavailable", log_request_id, provider_log_id, channel) auth = manifest["auth"] identity_required = ( @@ -244,20 +335,17 @@ def dispatch(self, dispatch, request_provider, shutter, request_id, log): if credential_unavailable: return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) - endpoint = self._resolve_endpoint(manifest) - if not endpoint: - return 502, self._fail_body(provider_id, channel, "provider endpoint not configured", dispatch, request_id) + endpoint = self.env.get("EPP_PROVIDER_ENDPOINT") + if not is_valid_provider_endpoint(endpoint): + return 502, self._fail_body(provider_id, channel, "provider endpoint must be an absolute HTTPS URL", dispatch, request_id) provider_request = adapter.build_request(channel, endpoint, dispatch, credential, self.env) - log.info("[DISPATCH] requestId=%s provider=%s channel=%s shutter=%s", request_id, provider_id, channel, bool(shutter)) - - if shutter: - return 200, {"status": "accepted", "shutterProcessed": True, "provider": provider_id, "channel": channel, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} + if not is_valid_provider_endpoint(provider_request["url"]): + return 502, self._fail_body( + provider_id, channel, "provider request URL must be absolute HTTPS", dispatch, request_id + ) - try: - timeout_ms = int(self.env.get("EPP_PROVIDER_TIMEOUT_MS") or DEFAULT_TIMEOUT_MS) - except (TypeError, ValueError): - timeout_ms = DEFAULT_TIMEOUT_MS + timeout_ms = normalize_provider_timeout_ms(self.env.get("EPP_PROVIDER_TIMEOUT_MS")) try: response = requests.request( provider_request["method"], @@ -267,11 +355,11 @@ def dispatch(self, dispatch, request_provider, shutter, request_id, log): timeout=timeout_ms / 1000, ) except requests.exceptions.Timeout: - log.warning("[DISPATCH_TIMEOUT] requestId=%s provider=%s", request_id, provider_id) - return 504, self._fail_body(provider_id, channel, f"endpoint timeout after {timeout_ms}ms", dispatch, request_id) - except requests.exceptions.RequestException as error: - log.error("[DISPATCH_ERROR] requestId=%s provider=%s reason=%s", request_id, provider_id, error) - return 502, self._fail_body(provider_id, channel, str(error), dispatch, request_id) + log.warning("[DISPATCH] requestId=%s provider=%s channel=%s timeout", log_request_id, provider_log_id, channel) + return 504, self._fail_body(provider_id, channel, "provider request timed out", dispatch, request_id) + except requests.exceptions.RequestException: + log.error("[DISPATCH] requestId=%s provider=%s channel=%s request failed", log_request_id, provider_log_id, channel) + return 502, self._fail_body(provider_id, channel, "provider request failed", dispatch, request_id) try: body_json = response.json() @@ -281,9 +369,12 @@ def dispatch(self, dispatch, request_provider, shutter, request_id, log): ok = 200 <= response.status_code < 300 parsed = adapter.parse_response(response.status_code, ok, body_json) outcome = resolve_outcome(manifest, parsed) + if outcome not in OUTCOMES: + outcome = FAIL http_status = to_http_status(outcome, parsed.get("provider_http_status") or response.status_code) - log.info("[DISPATCH_RESULT] requestId=%s provider=%s channel=%s outcome=%s httpStatus=%s", request_id, provider_id, channel, outcome, http_status) + log.info("[DISPATCH] requestId=%s provider=%s channel=%s httpStatus=%d outcome=%s", + log_request_id, provider_log_id, channel, http_status, outcome) return http_status, { "status": "accepted" if outcome == CONTINUE else "failed", @@ -294,20 +385,16 @@ def dispatch(self, dispatch, request_provider, shutter, request_id, log): "correlationId": dispatch.correlation_id, "providerMessageId": parsed.get("provider_message_id"), "providerStatus": parsed.get("provider_status_name") or parsed.get("provider_status_code"), - "providerStatusDescription": parsed.get("provider_status_description"), "requestId": request_id, } def _resolve_credential(self, auth): - if auth.get("mode") == "oauth2": - return {"mode": "oauth2", "token": None} # not wired -> fails closed + mode = (self.env.get("EPP_PROVIDER_AUTH_MODE") or auth.get("mode") or "apiKey").lower() + if mode == "oauth2": + return {"mode": "oauth2", "token": acquire_provider_token(self.env, self.secrets)} secret = self.secrets.resolve(auth.get("key_vault_secret_name")) identity = self.secrets.resolve(auth.get("identity_key_vault_secret_name")) if auth.get("identity_key_vault_secret_name") else "" return {"mode": "apiKey", "secret": secret, "identity": identity} - def _resolve_endpoint(self, manifest): - # One provider is active per deployment, so the endpoint is a single EPP_PROVIDER_ENDPOINT. - return self.env.get("EPP_PROVIDER_ENDPOINT") - def _fail_body(self, provider, channel, reason, dispatch, request_id): return {"status": "failed", "outcome": "Fail", "provider": provider, "channel": channel, "reason": reason, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index ac44b8f..0635b36 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -1,5 +1,6 @@ -"""Soprano Connect (MEMS): POST {base}/messages/{sms|voice}. -Auth: X-MEMS-API-ID + X-MEMS-API-Key.""" +"""Soprano Connect (MEMS): POST {base}/messages/omnimsg. +One endpoint for every channel - `messageTypes` picks it and Soprano does the TTS for voice. +Auth: an Entra ID v2.0 Bearer JWT (audience = Soprano's app id), or X-MEMS-API-ID + X-MEMS-API-Key.""" import json @@ -14,12 +15,13 @@ class SopranoProvider: "response_mapping": { "ENROUTE": "Continue", "ACCEPTED": "Continue", "SUBMITTED": "Continue", "SENT": "Continue", "DELIVERED": "Continue", "QUEUED": "Continue", + # FILTERED: accepted (HTTP 201) but stopped by an account/destination filter - nothing delivered. + "FILTERED": "Fail", "FAILED": "Fail", "REJECTED": "Fail", "BLOCKED": "Block", "default": "Fail", }, } def build_request(self, channel, endpoint, dispatch, credential, env): - message_type = "voice" if channel == "voice" else "sms" headers = {"Content-Type": "application/json", "Accept": "application/json"} if credential["mode"] == "oauth2": headers["Authorization"] = f"Bearer {credential['token']}" @@ -27,34 +29,16 @@ def build_request(self, channel, endpoint, dispatch, credential, env): headers["X-MEMS-API-ID"] = credential.get("identity") or "" headers["X-MEMS-API-Key"] = credential.get("secret") or "" - client_reference = dispatch.correlation_id or dispatch.message_id - body = {"messageType": message_type, "destination": dispatch.destination, "clientReference": client_reference} - - # Sender: a provisioned source endpoint is what Soprano accepts; free-text source is a fallback. - # Soprano wants a provisioned source endpoint (endpoints:[{type,id}]), which is numeric. A - # non-numeric account name is sent as a free-text source instead. - account = env.get("EPP_PROVIDER_ACCOUNT_NAME") - if account and str(account).isdigit(): - source_type = int(env.get("SOPRANO_SOURCE_TYPE") or 1) - body["endpoints"] = [{"type": source_type, "id": int(account)}] - elif account: - body["source"] = account - - if message_type == "voice": - locale = dispatch.locale or "" - voice_language = env.get("SOPRANO_VOICE_LANGUAGE") or (locale if "-" in locale else "en-US") - body["voice"] = {"text2voice": { - "beforePasswordText": dispatch.message or "", - "password": "", - "afterPasswordText": "", - "language": voice_language, - "gender": int(env.get("SOPRANO_VOICE_GENDER") or 1), - "loop": 1, - }} - else: - body["text"] = dispatch.message + body = { + "text": dispatch.message, + "destination": str(dispatch.destination or "").lstrip("+"), # E.164 without the leading + + "messageTypes": ["voice" if channel == "voice" else "sms"], + "correlationId": dispatch.correlation_id or dispatch.message_id, + # Soprano processes the request but delivers nothing - connectivity/credential testing. + "shutterMode": str(env.get("SOPRANO_SHUTTER_MODE") or "").lower() == "true", + } - return {"url": f"{endpoint}/messages/{message_type}", "method": "POST", "headers": headers, "body": json.dumps(body)} + return {"url": f"{endpoint}/messages/omnimsg", "method": "POST", "headers": headers, "body": json.dumps(body)} def parse_response(self, http_status, ok, json_body): payload = json_body[0] if isinstance(json_body, list) and json_body else json_body diff --git a/python/src/security.py b/python/src/security.py index 0e69006..efda2db 100644 --- a/python/src/security.py +++ b/python/src/security.py @@ -1,5 +1,5 @@ """Validates the Entra JWT when EPP_REQUIRE_AUTH=true (aud/issuer/JWKS, RS256). -No-op pass-through otherwise — Easy Auth is the primary gate; this is the backstop.""" +No-op pass-through otherwise. Easy Auth is the primary gate; this is the backstop.""" import os import jwt @@ -18,7 +18,11 @@ def _jwks_client(tenant_id): def validate_token(authorization_header): """Returns (ok, reason, caller_object_id).""" - if (os.environ.get("EPP_REQUIRE_AUTH") or "").lower() != "true": + require_auth = (os.environ.get("EPP_REQUIRE_AUTH") or "").lower() == "true" + running_in_azure = bool(os.environ.get("WEBSITE_INSTANCE_ID") or os.environ.get("WEBSITE_HOSTNAME")) + if not require_auth and running_in_azure: + return False, "EPP_REQUIRE_AUTH must be true in Azure", None + if not require_auth: return True, None, None audience = os.environ.get("EPP_EXPECTED_AUDIENCE") diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index db1cb31..c9194e4 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -1,11 +1,16 @@ -"""Conformance tests for the pure contract logic (see /docs/CONTRACT.md §6).""" +"""Representative provider wire contracts and fail-closed outcome mapping.""" +import base64 +import json +from urllib.parse import parse_qs + +import pytest + from src.dispatch import ( BLOCK, CONTINUE, FAIL, STEP_UP, DispatchRequest, - ProviderRegistry, resolve_outcome, to_http_status, ) @@ -15,51 +20,77 @@ from src.providers.sinch import SinchProvider -def _dispatch(channel="sms", message=None): +def _dispatch(channel="sms"): return DispatchRequest( - destination="+15551234567", message=message, channel=channel, + destination="+15551234567", message="code 918273", channel=channel, message_id="m", correlation_id="c", locale=None, ) -def test_outcome_and_http_status(): - manifest = InfobipProvider.manifest - assert resolve_outcome(manifest, {"success": True, "provider_status_name": "DELIVERED"}) == CONTINUE - # Unknown status fails closed even on HTTP 200. - assert resolve_outcome(manifest, {"success": True, "provider_status_name": "WATWAT"}) == FAIL - assert to_http_status(CONTINUE, 200) == 200 - assert to_http_status(BLOCK, 200) == 403 - assert to_http_status(STEP_UP, 200) == 409 - assert to_http_status(FAIL, 429) == 429 - assert to_http_status(FAIL, 403) == 401 - assert to_http_status(FAIL, 422) == 400 - assert to_http_status(FAIL, 500) == 502 +@pytest.mark.parametrize("adapter,body,status,expected", [ + (InfobipProvider(), {"messages": [{"status": {"name": "DELIVERED"}}]}, 401, 401), + (TelesignProvider(), {"status": {"code": 290}}, 429, 429), + (SopranoProvider(), {"status": "ENROUTE"}, 500, 502), + (SinchProvider(), {"status": "Dispatched"}, 401, 401), +]) +def test_http_failure_overrides_success_body(adapter, body, status, expected): + assert resolve_outcome(adapter.manifest, adapter.parse_response(200, True, body)) == CONTINUE + outcome = resolve_outcome(adapter.manifest, adapter.parse_response(status, False, body)) + assert outcome == FAIL + assert to_http_status(outcome, status) == expected + + +@pytest.mark.parametrize("status,outcome,http_status", [("BLOCKED", BLOCK, 403), ("RISK", STEP_UP, 409)]) +def test_policy_outcomes_survive_http_failure(status, outcome, http_status): + manifest = {"response_mapping": {"BLOCKED": BLOCK, "RISK": STEP_UP, "default": FAIL}} + resolved = resolve_outcome(manifest, {"success": False, "provider_status_name": status}) + assert resolved == outcome + assert to_http_status(resolved, 500) == http_status def test_infobip_builds_https_sms_request(): - env = {"INFOBIP_SENDER_ID": "EPP"} request = InfobipProvider().build_request( - "sms", "https://api.infobip.com", - _dispatch(message="Use verification code 918273 for Microsoft authentication."), - {"mode": "apiKey", "secret": "ib"}, env, + "sms", "https://api.infobip.com", _dispatch(), + {"mode": "apiKey", "secret": "ib"}, {"EPP_PROVIDER_ACCOUNT_NAME": "EPP"}, ) - assert request["url"].startswith("https://") - assert request["url"].endswith("/sms/3/messages") - assert request["headers"]["Authorization"].startswith("App ") - assert "918273" in request["body"] + assert (request["method"], request["url"]) == ("POST", "https://api.infobip.com/sms/3/messages") + assert request["headers"]["Authorization"] == "App ib" + message = json.loads(request["body"])["messages"][0] + assert message["sender"] == "EPP" + assert message["content"]["text"] == "code 918273" -def test_telesign_basic_auth_and_voice_mapping(): +def test_telesign_basic_auth_and_form_payload(): request = TelesignProvider().build_request( - "sms", "https://rest-api.telesign.com", _dispatch(message="code 918273"), + "sms", "https://rest-api.telesign.com", _dispatch(), {"mode": "apiKey", "secret": "key", "identity": "cust"}, {}, ) - assert request["headers"]["Authorization"].startswith("Basic ") - assert request["url"].endswith("/v1/messaging") - assert resolve_outcome(TelesignProvider.manifest, {"success": True, "provider_status_code": "100"}) == CONTINUE + assert request["headers"]["Authorization"] == "Basic " + base64.b64encode(b"cust:key").decode() + assert request["url"] == "https://rest-api.telesign.com/v1/messaging" + assert parse_qs(request["body"])["message"] == ["code 918273"] -def test_registry_resolves_by_id(): - registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) - assert registry.get("TELESIGN").manifest["id"] == "telesign" - assert registry.get("nope") is None +@pytest.mark.parametrize("channel", ["sms", "voice"]) +def test_soprano_api_key_and_omnimsg_payload(channel): + request = SopranoProvider().build_request( + channel, "https://qa.example.com/cgpapi", _dispatch(channel), + {"mode": "apiKey", "secret": "k", "identity": "id"}, {}, + ) + assert (request["method"], request["url"]) == ("POST", "https://qa.example.com/cgpapi/messages/omnimsg") + assert request["headers"]["X-MEMS-API-ID"] == "id" + assert request["headers"]["X-MEMS-API-Key"] == "k" + assert "Authorization" not in request["headers"] + assert json.loads(request["body"]) == { + "messageTypes": [channel], "destination": "15551234567", "text": "code 918273", + "correlationId": "c", "shutterMode": False, + } + + +def test_sinch_bearer_and_sms_payload(): + request = SinchProvider().build_request( + "sms", "https://sms.api.sinch.com", _dispatch(), + {"mode": "apiKey", "secret": "st"}, {"SINCH_SERVICE_PLAN_ID": "plan"}, + ) + assert request["url"] == "https://sms.api.sinch.com/xms/v1/plan/batches" + assert request["headers"]["Authorization"] == "Bearer st" + assert json.loads(request["body"])["body"] == "code 918273" diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index f32a1ec..13b1061 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -1,129 +1,109 @@ -"""Engine-level conformance tests (CONTRACT.md §6) with mocked HTTP + Key Vault.""" -import json +"""Representative dispatch failures and provider OAuth; all I/O is mocked.""" +import logging +from unittest.mock import Mock import pytest import src.dispatch as dispatch_module from src.dispatch import DispatchEngine, DispatchRequest, ProviderRegistry from src.providers.infobip import InfobipProvider -from src.providers.sinch import SinchProvider from src.providers.soprano import SopranoProvider -from src.providers.telesign import TelesignProvider -class FakeSecrets: - def __init__(self, values): - self._values = values - - def resolve(self, name): - return self._values.get(name, "") - - -class FakeResponse: - def __init__(self, status_code, body): - self.status_code = status_code - self._body = body - - def json(self): - return self._body - - -class CapturingLog: - def __init__(self): - self.lines = [] - - def _record(self, fmt, *args): - self.lines.append(fmt % args if args else fmt) - - info = _record - warning = _record - error = _record +_DEFAULT_ENV = {"EPP_PROVIDER_ENDPOINT": "https://api.infobip.com"} +_OAUTH_ENV = { + "EPP_PROVIDER_ENDPOINT": "https://api.soprano.com", "EPP_PROVIDER_AUTH_MODE": "oauth2", + "EPP_PROVIDER_TENANT_ID": "tenant", "EPP_PROVIDER_CLIENT_ID": "client", + "EPP_PROVIDER_SCOPE": "api://provider/.default", "EPP_PROVIDER_CLIENT_SECRET": "s", +} -_DEFAULT_SECRETS = { - "infobip-api-key": "ib", - "telesign-api-key": "ts", "telesign-customer-id": "cust", - "soprano-api-key": "sp", "soprano-api-id": "spid", -} -_DEFAULT_ENV = { - "EPP_PROVIDER_ENDPOINT": "https://api.infobip.com", -} +@pytest.fixture(autouse=True) +def send(monkeypatch): + monkeypatch.setattr(dispatch_module, "_provider_token_cache", {}) + request = Mock(side_effect=AssertionError("unexpected provider request")) + monkeypatch.setattr(dispatch_module.requests, "request", request) + return request -def make_engine(secret_values=None, env=None): - registry = ProviderRegistry([InfobipProvider(), TelesignProvider(), SopranoProvider(), SinchProvider()]) - secrets = FakeSecrets(_DEFAULT_SECRETS if secret_values is None else secret_values) +def make_engine(env=None, secret="key"): + registry = ProviderRegistry([InfobipProvider(), SopranoProvider()]) + secrets = Mock(resolve=Mock(return_value=secret)) return DispatchEngine(registry, secrets, _DEFAULT_ENV if env is None else env) -def dispatch_request(**overrides): - base = dict( +def dispatch_request(): + return DispatchRequest( destination="+15551234567", message="Your code is 918273", channel="sms", message_id="m", correlation_id="c", locale=None, ) - base.update(overrides) - return DispatchRequest(**base) - -def _mock_send(monkeypatch, response=None, raise_error=None, capture=None): - def fake_request(method, url, headers=None, data=None, timeout=None): - if capture is not None: - capture["url"] = url - capture["data"] = data - if raise_error is not None: - raise raise_error - return response - monkeypatch.setattr(dispatch_module.requests, "request", fake_request) +@pytest.mark.parametrize("env,secret,reason", [ + (_DEFAULT_ENV, "", "provider credential unavailable"), + ({}, "key", "provider endpoint must be an absolute HTTPS URL"), + ({"EPP_PROVIDER_ENDPOINT": "http://api.example.com"}, "key", "provider endpoint must be an absolute HTTPS URL"), +]) +def test_invalid_provider_config_does_not_send(send, env, secret, reason): + status, body = make_engine(env, secret).dispatch(dispatch_request(), "infobip", False, "r", logging) + assert status == 502 and body["reason"] == reason + send.assert_not_called() -def test_unknown_provider_400(): - status, body = make_engine().dispatch(dispatch_request(), "nope", False, "r", CapturingLog()) - assert status == 400 and body["reason"] == "unknown provider" - - -def test_missing_credential_502(): - status, body = make_engine(secret_values={}).dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert status == 502 and body["reason"] == "provider credential unavailable" - -def test_missing_endpoint_502(): - engine = make_engine(env={}) # no *_ENDPOINT set - status, body = engine.dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert status == 502 and body["reason"] == "provider endpoint not configured" - - -def test_shutter_does_not_send(monkeypatch): - _mock_send(monkeypatch, raise_error=AssertionError("should not send")) - status, body = make_engine().dispatch(dispatch_request(), "infobip", True, "r", CapturingLog()) +def test_shutter_does_not_need_credentials_or_endpoint(send): + status, body = make_engine(env={}, secret="").dispatch( + dispatch_request(), "infobip", True, "r", logging + ) assert status == 200 and body["shutterProcessed"] is True - - -def test_success_renders_code_and_keeps_privacy(monkeypatch): - capture = {} - _mock_send(monkeypatch, response=FakeResponse(200, {"messages": [{"status": {"name": "DELIVERED"}, "messageId": "x"}]}), capture=capture) - log = CapturingLog() - status, body = make_engine().dispatch(dispatch_request(), "infobip", False, "r", log) - - assert status == 200 and body["status"] == "accepted" - assert "918273" in capture["data"] # the message (with the code) IS sent to the provider (that's the delivery) - serialized = json.dumps(body) - assert "918273" not in serialized and "5551234567" not in serialized # never in the response body - assert all("918273" not in line and "5551234567" not in line for line in log.lines) # never logged - - -def test_unknown_status_fails_closed(monkeypatch): - _mock_send(monkeypatch, response=FakeResponse(200, {"messages": [{"status": {"name": "WATWAT"}}]})) - status, body = make_engine().dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert body["outcome"] == "Fail" and body["status"] == "failed" - - -def test_timeout_maps_to_504(monkeypatch): - _mock_send(monkeypatch, raise_error=dispatch_module.requests.exceptions.Timeout()) - status, _ = make_engine().dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert status == 504 - - -def test_network_error_maps_to_502(monkeypatch): - _mock_send(monkeypatch, raise_error=dispatch_module.requests.exceptions.ConnectionError()) - status, _ = make_engine().dispatch(dispatch_request(), "infobip", False, "r", CapturingLog()) - assert status == 502 + send.assert_not_called() + + +def test_unknown_provider_status_fails_closed(send): + send.side_effect = None + send.return_value = Mock(status_code=200, json=Mock(return_value={"messages": [{ + "status": {"name": "UNRECOGNIZED"}, + }]})) + status, body = make_engine().dispatch(dispatch_request(), "infobip", False, "r", logging) + assert (status, body["outcome"]) == (502, "Fail") + assert send.call_args.kwargs["timeout"] == 1.5 + + +@pytest.mark.parametrize("error,status,reason", [ + (dispatch_module.requests.exceptions.Timeout, 504, "provider request timed out"), + (dispatch_module.requests.exceptions.ConnectionError, 502, "provider request failed"), +]) +def test_timeout_and_network_failure_remain_distinct(send, error, status, reason): + engine = make_engine(env={**_DEFAULT_ENV, "EPP_PROVIDER_TIMEOUT_MS": "999999"}) + send.side_effect = error("private provider exception") + actual, body = engine.dispatch(dispatch_request(), "infobip", False, "r", logging) + assert actual == status and body["reason"] == reason + assert send.call_args.kwargs["timeout"] == 2.5 + + +def test_oauth_sends_and_caches_minted_bearer(monkeypatch, send): + send.side_effect = None + send.return_value = Mock(status_code=201, json=Mock(return_value={"status": "ENROUTE"})) + credential = Mock(get_token=Mock(return_value=Mock(token="minted-token-secret", expires_on=9999999999))) + factory = Mock(return_value=credential) + monkeypatch.setattr(dispatch_module, "ClientSecretCredential", factory) + engine = make_engine(env=_OAUTH_ENV) + first = engine.dispatch(dispatch_request(), "soprano", False, "r1", logging) + second = engine.dispatch(dispatch_request(), "soprano", False, "r2", logging) + assert first[0] == second[0] == 200 + assert send.call_count == 2 + headers = send.call_args_list[0].kwargs["headers"] + assert headers == send.call_args_list[1].kwargs["headers"] + assert headers["Authorization"] == "Bearer minted-token-secret" + assert "X-MEMS-API-Key" not in headers + factory.assert_called_once_with("tenant", "client", "s") + credential.get_token.assert_called_once_with("api://provider/.default") + + +@pytest.mark.parametrize("configured", [False, True]) +def test_oauth_fails_closed_without_token(monkeypatch, send, configured): + credential = Mock(get_token=Mock(return_value=Mock(token="", expires_on=9999999999))) + monkeypatch.setattr(dispatch_module, "ClientSecretCredential", Mock(return_value=credential)) + env = _OAUTH_ENV if configured else {"EPP_PROVIDER_AUTH_MODE": "oauth2"} + status, body = make_engine(env=env).dispatch(dispatch_request(), "soprano", False, "r", logging) + assert status == 502 and body["reason"] == "provider credential unavailable" + send.assert_not_called() diff --git a/python/tests/test_envelope.py b/python/tests/test_envelope.py index 091c704..b801814 100644 --- a/python/tests/test_envelope.py +++ b/python/tests/test_envelope.py @@ -1,6 +1,7 @@ -"""Envelope validation + JWE decryption round-trip (see docs/CONTRACT.md §1, §6).""" +"""Essential envelope validation, authenticated JWE, and rendered message preservation.""" import json +import pytest from jwcrypto import jwe, jwk from src.dispatch import ( @@ -9,87 +10,74 @@ parse_envelope, ) -# Throwaway RSA key: encrypt here, decrypt via the module using the private PEM. _KEY = jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") _PRIVATE_PEM = _KEY.export_to_pem(private_key=True, password=None).decode("utf-8") +_CONTEXT = {"nonce": "nonce-1", "phoneNumber": "+14255551234", "message": "Your code is 123456", "locale": "en-US"} +_ENVELOPE = {"type": "microsoft.mfa.otpDeliver.v1", "channel": 1, "mode": 1, "encryptedDeliveryContext": "x"} + + +@pytest.mark.parametrize("payload,error", [ + ([], "invalid envelope"), + ({**_ENVELOPE, "type": "unsupported"}, "unsupported type"), + ({**_ENVELOPE, "encryptedDeliveryContext": None}, "encryptedDeliveryContext is required"), + ({**_ENVELOPE, "channel": "1"}, "unsupported channel"), + ({**_ENVELOPE, "mode": True}, "unsupported mode"), +]) +def test_malformed_envelope_categories(payload, error): + assert parse_envelope(payload) == (None, error) + + +@pytest.mark.parametrize("ttl_seconds,error", [ + (None, "ttlSeconds must be a positive integer"), + (True, "ttlSeconds must be a positive integer"), + (0.5, "ttlSeconds must be a positive integer"), + ("60", "ttlSeconds must be a positive integer"), + (0, "passcode has expired"), +]) +def test_invalid_or_expired_ttl(ttl_seconds, error): + assert parse_envelope({**_ENVELOPE, "ttlSeconds": ttl_seconds}) == (None, error) + + +@pytest.mark.parametrize("channel,mode,expected,ttl", [ + (1, 1, (1, 1), {}), ("VOICE", "EVALUATION", (2, 2), {"ttlSeconds": 60}), +]) +def test_valid_routing_and_optional_ttl(channel, mode, expected, ttl): + envelope, error = parse_envelope({**_ENVELOPE, "channel": channel, "mode": mode, **ttl}) + assert error is None + assert (envelope["channel"], envelope["mode"]) == expected + assert envelope["ttl_seconds"] == ttl.get("ttlSeconds") -def _encrypt(context, kid="test-key"): - protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": kid} - token = jwe.JWE(json.dumps(context).encode("utf-8"), protected=json.dumps(protected)) +@pytest.fixture +def encrypted_context(): + protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": "test-key"} + token = jwe.JWE(json.dumps(_CONTEXT).encode("utf-8"), protected=json.dumps(protected)) token.add_recipient(_KEY) return token.serialize(compact=True) -def _key_provider(_kid): - return _PRIVATE_PEM - - -def _sample_context(): - return {"nonce": "nonce-1", "phoneNumber": "+14255551234", "message": "Your code is 123456", "locale": "en-US"} - +def test_real_jwe_round_trip(encrypted_context): + header, context = decrypt_delivery_context(encrypted_context, lambda _kid: _PRIVATE_PEM) + assert header == {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": "test-key"} + assert context == _CONTEXT -def test_missing_encrypted_context_is_error(): - envelope, error = parse_envelope({"channel": 1, "mode": 1}) - assert envelope is None - assert "encryptedDeliveryContext" in error +def test_tampered_ciphertext_is_rejected(encrypted_context): + parts = encrypted_context.split(".") + parts[3] = ("A" if parts[3][0] != "A" else "B") + parts[3][1:] + with pytest.raises(jwe.InvalidJWEData): + decrypt_delivery_context(".".join(parts), lambda _kid: _PRIVATE_PEM) -def test_unsupported_channel_is_error(): - envelope, error = parse_envelope({"channel": 9, "mode": 1, "encryptedDeliveryContext": "x"}) - assert envelope is None - assert "channel" in error - -def test_unsupported_mode_is_error(): - envelope, error = parse_envelope({"channel": 1, "mode": 5, "encryptedDeliveryContext": "x"}) - assert envelope is None - assert "mode" in error - - -def test_valid_envelope_parses(): - envelope, error = parse_envelope({ - "type": "microsoft.mfa.otpDeliver.v1", "tenantId": "t", "correlationId": "c", - "channel": 2, "mode": 1, "ttlSeconds": 60, "encryptedDeliveryContext": "x", - }) - assert error is None - assert envelope["channel"] == 2 - assert envelope["mode"] == 1 - - -def test_jwe_round_trips_to_delivery_context(): - compact = _encrypt(_sample_context()) - header, context = decrypt_delivery_context(compact, _key_provider) - assert header["kid"] == "test-key" - assert header["alg"] == "RSA-OAEP-256" - assert header["enc"] == "A256GCM" - assert context["nonce"] == "nonce-1" - assert context["phoneNumber"] == "+14255551234" - assert context["message"] == "Your code is 123456" - - -def test_context_to_dispatch_maps_fields(): - envelope, _ = parse_envelope({ - "correlationId": "corr-1", "channel": 2, "mode": 1, "encryptedDeliveryContext": "x", - }) - dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") - assert dispatch.destination == "+14255551234" - assert dispatch.channel == "voice" - assert dispatch.message_id == "msg-1" - assert dispatch.correlation_id == "corr-1" - # Voice must read the passcode digit by digit. - assert "1 2 3 4 5 6" in dispatch.message - - -def test_sms_message_is_left_intact(): - envelope, _ = parse_envelope({"channel": 1, "mode": 1, "encryptedDeliveryContext": "x"}) - dispatch = context_to_dispatch(_sample_context(), envelope, "msg-1") - assert dispatch.message == "Your code is 123456" - - -def test_base64_wrapped_key_is_accepted(): - """The setup script stores EPP_DECRYPTION_KEY_PEM as base64 over the PEM.""" - import base64 as _b64 - wrapped = _b64.b64encode(_PRIVATE_PEM.encode("utf-8")).decode("ascii") - _header, context = decrypt_delivery_context(_encrypt(_sample_context()), lambda _kid: wrapped) - assert context["nonce"] == "nonce-1" +@pytest.mark.parametrize("channel,expected,message", [ + (1, "sms", "Réf. 2026/42: code 123456; appelez +33 (0)1 23 45 67 89!\r\n"), + (2, "voice", " Votre code : 1 2 3 4 5 6. Référence 987654; montant 12,50 €! "), +]) +def test_context_mapping_preserves_rendered_message_bytes(channel, expected, message): + envelope, _ = parse_envelope({**_ENVELOPE, "channel": channel, "correlationId": "corr-1"}) + dispatch = context_to_dispatch({**_CONTEXT, "message": message}, envelope, "msg-1") + assert dispatch.destination == _CONTEXT["phoneNumber"] + assert dispatch.locale == _CONTEXT["locale"] + assert dispatch.channel == expected + assert dispatch.message_id == "msg-1" and dispatch.correlation_id == "corr-1" + assert dispatch.message.encode("utf-8") == message.encode("utf-8") diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index 87d6526..5345cd9 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -1,81 +1,64 @@ -"""Trigger-level tests for the SendOtp HTTP handler. - -The import + route assertions are the regression guard for module-level breakage: a bad `from src...` -line makes the whole Function App fail to start, and the engine-level tests never import this module, -so they stay green while nothing can run. -""" +"""Real Function handler smoke tests and minimal mocked inbound JWT validation.""" import json -import os +import logging +from unittest.mock import Mock import azure.functions as func import pytest from jwcrypto import jwe, jwk import src.dispatch as dispatch_module +import src.security as security +import function_app -# Set before importing function_app: it builds its engine and key provider at module load. _KEY = jwk.JWK.generate(kty="RSA", size=2048, kid="test-key") -os.environ["EPP_DECRYPTION_KEY_PEM"] = _KEY.export_to_pem(private_key=True, password=None).decode("utf-8") -os.environ["EPP_PROVIDER_NAME"] = "infobip" -os.environ["EPP_PROVIDER_ENDPOINT"] = "https://api.infobip.com" - -import function_app # noqa: E402 # Resolved once: app.get_functions() rebuilds bindings and rejects a second call. -_FUNCTIONS = function_app.app.get_functions() -_HANDLER = _FUNCTIONS[0].get_user_function() - - -class _FakeSecrets: - def resolve(self, name): - return "ib" - - -class _FakeResponse: - def __init__(self, status_code, body): - self.status_code = status_code - self._body = body - - def json(self): - return self._body - +_HANDLER = function_app.app.get_functions()[0].get_user_function() -class _InlineThread: - """Runs the background delivery inline so assertions don't race the worker thread.""" - - def __init__(self, target=None, name=None, daemon=None): - self._target = target - - def start(self): - self._target() +_CONTEXT = { + "nonce": "nonce-secret", + "phoneNumber": "+14255551234", + "locale": "locale-secret", + "message": "message-secret code 123456", +} +_CORRELATION = "correlation-secret" @pytest.fixture(autouse=True) -def _wire(monkeypatch): - monkeypatch.setattr(function_app._engine, "secrets", _FakeSecrets()) - monkeypatch.setattr(function_app.threading, "Thread", _InlineThread) - - -def _request(body): +def send(monkeypatch, caplog): + caplog.set_level(logging.INFO) + request = Mock(return_value=Mock(status_code=200, json=Mock(return_value={ + "messages": [{"status": {"name": "DELIVERED"}}], + }))) + monkeypatch.setattr(dispatch_module.requests, "request", request) + monkeypatch.setattr(function_app._engine, "secrets", Mock(resolve=Mock(return_value="key-secret"))) + monkeypatch.setattr(security, "_jwks_client", Mock(side_effect=AssertionError("unexpected JWKS lookup"))) + monkeypatch.setenv("EPP_REQUIRE_AUTH", "false") + monkeypatch.setenv("EPP_LOG_PLAINTEXT", "true") + monkeypatch.setenv("EPP_PROVIDER_NAME", "infobip") + monkeypatch.setenv("EPP_PROVIDER_ENDPOINT", "https://api.infobip.com") + monkeypatch.setenv("EPP_PROVIDER_AUTH_MODE", "apiKey") + monkeypatch.setenv("EPP_DECRYPTION_KEY_PEM", _KEY.export_to_pem(private_key=True, password=None).decode("utf-8")) + for setting in ("WEBSITE_INSTANCE_ID", "WEBSITE_HOSTNAME", "EPP_EXPECTED_CLIENT_ID"): + monkeypatch.delenv(setting, raising=False) + return request + + +def _request(body, headers=None): raw = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8") - return func.HttpRequest(method="POST", url="/api/SendOtp", headers={}, params={}, body=raw) + return func.HttpRequest(method="POST", url="/api/SendOtp", headers=headers or {}, params={}, body=raw) def _envelope(**overrides): - context = { - "nonce": "nonce-abc", - "phoneNumber": "+14255551234", - "locale": "en-US", - "message": "Your code is 123456", - } - protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": "test-key"} - token = jwe.JWE(json.dumps(context).encode("utf-8"), protected=json.dumps(protected)) + protected = {"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": "kid-secret"} + token = jwe.JWE(json.dumps(_CONTEXT).encode("utf-8"), protected=json.dumps(protected)) token.add_recipient(_KEY) envelope = { "type": "microsoft.mfa.otpDeliver.v1", - "tenantId": "tenant-1", - "correlationId": "corr-1", + "tenantId": "tenant-secret", + "correlationId": _CORRELATION, "channel": 1, "mode": 1, "ttlSeconds": 60, @@ -85,40 +68,85 @@ def _envelope(**overrides): return envelope -def test_app_imports_and_registers_the_route(): - assert [f.get_function_name() for f in _FUNCTIONS] == ["send_otp"] - - -def test_invalid_json_is_400(): - response = _HANDLER(_request(b"{ not json")) - assert response.status_code == 400 - - -def test_live_envelope_echoes_the_nonce(monkeypatch): - sent = {} - - def fake_request(method, url, headers=None, data=None, timeout=None): - sent["url"] = url - return _FakeResponse(200, {"messages": [{"status": {"groupName": "PENDING"}, "messageId": "x"}]}) +def test_acceptance_preserves_wire_correlation_without_logging_pii(caplog, send): + envelope = _envelope() + response = _HANDLER(_request(envelope, { + "x-ms-correlation-id": "ignored-header-secret", "Authorization": "Bearer token-secret", + })) + assert response.status_code == 200 + assert json.loads(response.get_body()) == { + "nonce": _CONTEXT["nonce"], "correlationId": _CORRELATION, "providerStatus": "accepted", + } + send.assert_called_once() + message = json.loads(send.call_args.kwargs["data"])["messages"][0] + assert message["destinations"][0] == {"to": _CONTEXT["phoneNumber"], "messageId": _CORRELATION} + assert message["content"]["text"] == _CONTEXT["message"] + assert "[EPP] " in caplog.text + assert "secret" not in caplog.text.lower() + assert "4255551234" not in caplog.text and "123456" not in caplog.text + assert "-----BEGIN" not in caplog.text + assert envelope["encryptedDeliveryContext"] not in caplog.text + assert all(record.exc_info is None for record in caplog.records) + + +def test_evaluation_mode_decrypts_but_does_not_send(send): + response = _HANDLER(_request(_envelope(mode=2))) + assert response.status_code == 200 + assert json.loads(response.get_body())["nonce"] == _CONTEXT["nonce"] + send.assert_not_called() - monkeypatch.setattr(dispatch_module.requests, "request", fake_request) +def test_provider_failure_is_generic_for_sas_fallback(caplog, send): + send.return_value = Mock(status_code=401, json=Mock(return_value={"messages": [{ + "status": {"name": "REJECTED", "description": "provider-error-secret code 123456"}, + }]})) response = _HANDLER(_request(_envelope())) body = json.loads(response.get_body()) + assert response.status_code == 401 + assert body == { + "error": "provider_delivery_failed", "status": "failed", "outcome": "Fail", + "correlationId": _CORRELATION, "requestId": body["requestId"], + } + assert "secret" not in caplog.text.lower() and "123456" not in caplog.text - assert response.status_code == 200 - assert body["nonce"] == "nonce-abc" - assert body["correlationId"] == "corr-1" - assert sent["url"].startswith("https://") - - -def test_evaluation_mode_does_not_send(monkeypatch): - def fail(*args, **kwargs): - raise AssertionError("evaluation mode must not send") - monkeypatch.setattr(dispatch_module.requests, "request", fail) +def test_invalid_json_stops_before_delivery(send): + response = _HANDLER(_request(b"{ malformed")) + assert response.status_code == 400 + assert json.loads(response.get_body())["reason"] == "invalid JSON body" + send.assert_not_called() - response = _HANDLER(_request(_envelope(mode=2))) - assert response.status_code == 200 - assert json.loads(response.get_body())["nonce"] == "nonce-abc" +def test_azure_auth_guard_cannot_be_disabled(monkeypatch, send): + monkeypatch.setenv("WEBSITE_INSTANCE_ID", "azure-instance") + response = _HANDLER(_request(_envelope())) + assert response.status_code == 401 + body = json.loads(response.get_body()) + assert (body["error"], body["reason"]) == ("unauthorized", "token validation failed") + send.assert_not_called() + + +@pytest.mark.parametrize("failure", [None, "issuer", "audience"]) +def test_token_validation_checks_issuer_and_audience(monkeypatch, failure): + monkeypatch.setenv("EPP_REQUIRE_AUTH", "true") + monkeypatch.setenv("EPP_TENANT_ID", "tenant") + monkeypatch.setenv("EPP_EXPECTED_AUDIENCE", "audience") + monkeypatch.setenv("EPP_EXPECTED_ISSUER", "issuer") + client = Mock() + client.get_signing_key_from_jwt.return_value.key = "signing-key-secret" + decode = Mock(return_value={ + "iss": "wrong-issuer" if failure == "issuer" else "issuer", "oid": "object-secret", + }) + if failure == "audience": + decode.side_effect = security.jwt.InvalidAudienceError("wrong audience") + monkeypatch.setattr(security, "_jwks_client", Mock(return_value=client)) + monkeypatch.setattr(security.jwt, "decode", decode) + assert security.validate_token("Bearer token-secret") == ( + (True, None, "object-secret") if failure is None else (False, "token validation failed", None) + ) + security._jwks_client.assert_called_once_with("tenant") + client.get_signing_key_from_jwt.assert_called_once_with("token-secret") + decode.assert_called_once_with( + "token-secret", "signing-key-secret", algorithms=["RS256"], + audience="audience", options={"verify_iss": False}, + )