diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index ed06235..6f22c73 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -74,7 +74,7 @@ jobs: run: | $required = @{ 'epp-javascript.zip' = @('host.json', 'src/functions/SendOtp.js', 'node_modules/@azure/functions/package.json') - 'epp-dotnet-source.zip' = @('host.json', 'dotnet.csproj', 'Program.cs', 'Functions/SendOtp.cs', 'Src/DispatchEngine.cs') + 'epp-dotnet-source.zip' = @('host.json', 'dotnet.csproj', 'Program.cs', 'Functions/SendOtp.cs', 'Src/PhoneProviderBase.cs') 'epp-python-source.zip' = @('host.json', 'function_app.py', 'requirements.txt', 'src/dispatch.py') } $checksums = foreach ($name in ($required.Keys | Sort-Object)) { diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 5c19213..57ec15c 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -390,8 +390,9 @@ Per-request `providerCredentialElapsedMs` continues to measure the caller's reso set, else system-assigned). No static credentials. - **Privacy**: never log phone numbers, passcodes, nonce values, bearer tokens, API keys, JWE headers/payloads, raw exceptions, provider descriptions/responses or endpoint query strings. There is no plaintext diagnostic - override. Each handler emits separate service events and one [request summary](#application-logs), - with generated Function IDs distinguished from raw Microsoft/provider support IDs. Original wire IDs + override. Each handler emits separate service events. JavaScript and Python also emit one + [request summary](#application-logs); .NET uses standard structured `ILogger` events and scopes instead. + Generated Function IDs remain distinguished from raw Microsoft/provider support IDs. Original wire IDs and the required nonce echo remain unchanged. Support IDs can correlate customer activity; restrict log access and retention. Endpoint logs contain only scheme, host/port and API path, never userinfo, query strings or fragments. A configured encryption-key-ID mismatch adds a correlated fixed warning, @@ -413,13 +414,39 @@ Per-request `providerCredentialElapsedMs` continues to measure the caller's reso ### Application logs -All three implementations emit JSON records with the same field names. .NET also supplies these -fields as structured `ILogger` state. Service events have `logType: "service"` and an individual -`eventName`: they are emitted as the work happens, **not buffered or combined into a multi-step log**. -Each handler invocation ends with exactly one `logType: "request"`, `eventName: "request_completed"` -summary, including validation failures, evaluation and provider failures. - -A successful live request emits these separate service events, followed by the request summary: +JavaScript and Python emit JSON records with the shared fields described below. Service events have +`logType: "service"` and an individual `eventName`; each invocation ends with one +`logType: "request"`, `eventName: "request_completed"` summary. + +.NET uses the standard `ILogger` pipeline instead of manually serializing JSON. `OtpLog` defines +source-generated events with stable IDs and names, while `ILogger.BeginScope` supplies +`FunctionName`, `FunctionRequestId`, `FunctionInvocationId`, `MsClientRequestId`, +`MsCorrelationId` and `MsCorrelationIdSource`. The configured logging provider owns output +formatting and export. .NET emits `request_completed` as an ordinary typed event rather than a +mutable comprehensive summary. + +A successful .NET live request emits: + +`request_received`, `payload_validated`, `delivery_context_decrypted`, `provider_selected`, +`provider_credential_resolution_started`, `provider_credential_resolved`, +`provider_request_build_started`, `provider_request_built`, `provider_request_started`, +`provider_response_received`, `provider_response_processed`, `response_prepared`, +`request_completed`. + +Failures emit `request_failed` with fixed `FailureStage`, `FailureReason` and `HttpStatus` values. +Provider results retain the coarse endpoint `Outcome` plus a safe diagnostic classification: +`provider_http_error`, `provider_rejected`, `unrecognized_provider_status`, +`missing_provider_message_id` or `invalid_provider_json`. The structured +`provider_response_processed` event includes this classification together with the upstream HTTP +status and normalized provider status; raw provider descriptions and bodies remain excluded. +Credential refresh failures use `credential_refresh_failed`; unexpected unclassified failures use +`unexpected_error`. Typed event arguments are explicitly allowlisted and never include request +bodies, decrypted delivery fields, credentials, provider response bodies, query strings or private +exception messages. Endpoint values contain only scheme, host/port and path. Evaluation omits all +provider events and emits `evaluation_completed`. + +A successful JavaScript or Python live request emits these separate service events, followed by the +request summary: | Service event | Safe information recorded | |---|---| @@ -464,7 +491,7 @@ such as `decryption_failed`, `provider_credentials_failed` or `provider_transpor A parsed provider rejection uses `provider_response_processed` with its non-success outcome and fixed failure reason. -Every service event carries the Function request/invocation IDs, the Microsoft trace IDs available +In JavaScript and Python, every service event carries the Function request/invocation IDs, the Microsoft trace IDs available at that point, and the known channel, evaluation flag and selected provider. The initial event can only know header trace IDs; a valid envelope can subsequently supply the selected correlation. The generated Function request ID joins these events even when Microsoft IDs are absent or change @@ -496,7 +523,7 @@ These are tracing fields, not authentication assertions. In particular, an incom does not become a trusted tenant identity in logs. The existing wire correlation precedence, provider request IDs and public responses are unchanged. -The request summary contains: +The JavaScript and Python request summary contains: | Fields | Purpose | |---|---| diff --git a/dotnet/Functions/SendOtp.cs b/dotnet/Functions/SendOtp.cs index df9c65e..a776bf8 100644 --- a/dotnet/Functions/SendOtp.cs +++ b/dotnet/Functions/SendOtp.cs @@ -1,21 +1,32 @@ +using System.Net; +using System.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; +using WorkerFromBody = Microsoft.Azure.Functions.Worker.Http.FromBodyAttribute; namespace Epp.Otp; -// Echo the nonce only on acceptance; keep service events and the request summary PII-safe. +// Echo the nonce only on acceptance; keep structured service events PII-safe. public sealed class SendOtp { - private readonly DispatchEngine _engine; + public const string ProviderHttpClientName = "otp-provider"; + private const int DefaultTimeoutMs = 1500; + private const int MaxTimeoutMs = 2500; + private readonly IReadOnlyList _providers; + private readonly CredentialTokenService _credentials; + private readonly IHttpClientFactory _httpFactory; private readonly JweDecryptor _decryptor; private readonly IEnv _env; private readonly ILogger _log; - public SendOtp(DispatchEngine engine, JweDecryptor decryptor, IEnv env, ILogger log) + public SendOtp(IEnumerable providers, CredentialTokenService credentials, + IHttpClientFactory httpFactory, JweDecryptor decryptor, IEnv env, ILogger log) { - _engine = engine; + _providers = providers.ToArray(); + _credentials = credentials; + _httpFactory = httpFactory; _decryptor = decryptor; _env = env; _log = log; @@ -25,99 +36,290 @@ public SendOtp(DispatchEngine engine, JweDecryptor decryptor, IEnv env, ILogger< [Function("SendOtp")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "SendOtp")] HttpRequest req, + [WorkerFromBody] EntraSendOtpPayload payload, FunctionContext? functionContext = null) { + var started = Stopwatch.StartNew(); var requestId = Guid.NewGuid().ToString("n"); - var msRequestId = req.Headers["x-ms-client-request-id"].FirstOrDefault(); - var headerCorrelationId = req.Headers["x-ms-correlation-id"].FirstOrDefault(); - var log = new RequestLog(_log, requestId, functionContext?.InvocationId, msRequestId, headerCorrelationId); - var correlationId = headerCorrelationId ?? requestId; - var httpStatus = 500; - var evaluation = false; - - ObjectResult Reply(int status, object body) - { - var response = new ObjectResult(body) { StatusCode = status }; - httpStatus = status; - log.ResponsePrepared(status, body is EndpointSuccessResponse, - body is EndpointSuccessResponse or EndpointErrorResponse { CorrelationId: not null }); - return response; + var msRequestId = OtpLog.SafeIdentifier( + req.Headers["x-ms-client-request-id"].FirstOrDefault()); + var headerCorrelationId = OtpLog.SafeIdentifier( + req.Headers["x-ms-correlation-id"].FirstOrDefault()); + var payloadError = payload?.Validate(); + var payloadCorrelationId = payloadError is null + ? OtpLog.SafeIdentifier(payload?.CorrelationId) + : null; + using var scope = _log.BeginScope(new Dictionary + { + ["FunctionName"] = "SendOtp", + ["FunctionRequestId"] = requestId, + ["FunctionInvocationId"] = functionContext?.InvocationId, + ["MsClientRequestId"] = msRequestId, + ["MsCorrelationId"] = payloadCorrelationId ?? headerCorrelationId, + ["MsCorrelationIdSource"] = payloadCorrelationId is not null + ? "payload" + : headerCorrelationId is not null ? "header" : "none", + }); + int statusCode; + object body; + + try + { + OtpLog.RequestReceived(_log); + body = await ProcessAsync( + payload, payloadError, requestId, msRequestId, headerCorrelationId).ConfigureAwait(false); + statusCode = 200; + } + catch (InvalidRequestException exception) + { + statusCode = exception.StatusCode; + body = new EndpointErrorResponse( + exception.Error, + requestId, + exception.Reason, + exception.CorrelationId); + } + catch (Exception exception) + { + OtpLog.UnexpectedError(_log); + statusCode = exception is HttpRequestException { StatusCode: { } status } + && (int)status >= 500 + ? (int)status + : 500; + body = new EndpointErrorResponse( + statusCode == 500 ? "delivery_failed" : "provider_delivery_failed", + requestId, + CorrelationId: payload?.CorrelationId ?? headerCorrelationId ?? requestId); } + var containsNonce = body is EndpointSuccessResponse; + var containsCorrelationId = + body is EndpointSuccessResponse or EndpointErrorResponse { CorrelationId: not null }; + OtpLog.ResponsePrepared( + _log, + statusCode, + containsNonce, + containsCorrelationId); + OtpLog.RequestCompleted( + _log, + statusCode, + statusCode == 200 + ? payload?.IsEvaluation == true ? "evaluated" : "accepted" + : "failed", + started.ElapsedMilliseconds); + return new ObjectResult(body) { StatusCode = statusCode }; + } + + private async Task ProcessAsync( + EntraSendOtpPayload? payload, + string? payloadError, + string requestId, + string? msRequestId, + string? headerCorrelationId) + { + var config = AppConfig.Read(_env); + var clientRequestId = msRequestId ?? requestId; + + if (payload is null) + { + const string error = "invalid payload"; + OtpLog.RequestFailed( + _log, LogLevel.Warning, "request_validation", error, 400); + throw new InvalidRequestException(400, "bad_request", error); + } + if (payloadError is not null) + { + OtpLog.RequestFailed( + _log, LogLevel.Warning, "request_validation", payloadError, 400); + throw new InvalidRequestException(400, "bad_request", payloadError); + } + + var correlationId = payload.CorrelationId ?? headerCorrelationId ?? requestId; + OtpLog.PayloadValidated( + _log, + payload.Type, + payload.ChannelName, + payload.IsEvaluation, + payload.TtlSeconds); + + DecryptedPayload decrypted; try { - log.Service("request_received"); - var config = AppConfig.Read(_env); - var clientRequestId = msRequestId ?? requestId; - - var (envelope, envelopeError) = await EnvelopeParser.ParseAsync(req.Body, req.HttpContext.RequestAborted); - if (envelopeError is not null) - { - log.Failure("request_validation", envelopeError, 400); - return Reply(400, new EndpointErrorResponse("bad_request", requestId, Reason: envelopeError)); - } - - correlationId = envelope!.CorrelationId ?? correlationId; - evaluation = envelope.Mode == EnvelopeParser.ModeEvaluation; - log.EnvelopeValidated(envelope, envelope.CorrelationId ?? headerCorrelationId, - envelope.CorrelationId is not null ? "envelope" : "header"); - - JweResult decrypted; - try - { - decrypted = _decryptor.Decrypt(envelope.EncryptedDeliveryContext); - } - catch - { - log.Failure("decryption", "decryption_failed", 400); - return Reply(400, new EndpointErrorResponse("decryption_failed", requestId, CorrelationId: correlationId)); - } - log.Service("delivery_context_decrypted"); - - if (!string.IsNullOrEmpty(config.ExpectedKeyId) - && !string.Equals(config.ExpectedKeyId, decrypted.Kid, StringComparison.Ordinal)) - log.KeyIdMismatch(); - - var context = decrypted.Context; - if (!context.IsComplete) - { - log.Failure("delivery_context_validation", "incomplete delivery context", 400); - return Reply(400, new EndpointErrorResponse("bad_request", requestId, Reason: "incomplete delivery context", CorrelationId: correlationId)); - } - - // Evaluation proves validation/decryption without requiring any provider configuration. - if (evaluation) - { - log.Service("evaluation_completed"); - return Reply(200, new EndpointSuccessResponse(context.Nonce!, correlationId)); - } - - var channel = EnvelopeParser.ChannelName(envelope.Channel)!; - - var dispatch = new DispatchRequest( - Destination: context.PhoneNumber!, - Message: context.Message, - Channel: channel, - MessageId: clientRequestId, - CorrelationId: correlationId, - Locale: context.Locale, - TextToVoice: context.TextToVoice); - - // A nonce acknowledges delivery, not just decryption. Wait for the bounded provider call. - var result = await _engine.DispatchAsync(dispatch, requestId, log); - if (result.HttpStatus != 200) - return Reply(result.HttpStatus, new EndpointErrorResponse("provider_delivery_failed", requestId, CorrelationId: correlationId)); - - return Reply(200, new EndpointSuccessResponse(context.Nonce!, correlationId)); + decrypted = _decryptor.Decrypt( + payload.EncryptedDeliveryContext!); } catch { - if (!log.HasFailure) log.Failure("handler", "unexpected_error", 500); - return Reply(500, new EndpointErrorResponse("delivery_failed", requestId, CorrelationId: correlationId)); + OtpLog.RequestFailed( + _log, LogLevel.Warning, "decryption", "decryption_failed", 400); + throw new InvalidRequestException( + 400, "decryption_failed", correlationId: correlationId); + } + OtpLog.DeliveryContextDecrypted(_log); + + if (!string.IsNullOrEmpty(config.ExpectedKeyId) + && !string.Equals(config.ExpectedKeyId, decrypted.KeyId, StringComparison.Ordinal)) + OtpLog.EncryptionKeyIdMismatch(_log); + + var context = decrypted.Value; + if (!context.IsComplete) + { + OtpLog.RequestFailed( + _log, + LogLevel.Warning, + "delivery_context_validation", + "incomplete delivery context", + 400); + throw new InvalidRequestException( + 400, + "bad_request", + "incomplete delivery context", + correlationId); } - finally + + // Evaluation proves validation/decryption without requiring any provider configuration. + if (payload.IsEvaluation) + { + OtpLog.EvaluationCompleted(_log); + return new EndpointSuccessResponse(context.Nonce!, correlationId); + } + + var delivery = new OtpDelivery( + PhoneNumber: context.PhoneNumber!, + Message: context.Message, + Channel: payload.ChannelName, + MessageId: clientRequestId, + CorrelationId: correlationId, + Locale: context.Locale); + + // A nonce acknowledges delivery, not just decryption. Wait for the bounded provider call. + var providerStatus = await SendToProviderAsync(delivery).ConfigureAwait(false); + if (providerStatus >= 400) + throw new InvalidRequestException( + providerStatus, "provider_delivery_failed", correlationId: correlationId); + + return new EndpointSuccessResponse(context.Nonce!, correlationId); + } + + private async Task SendToProviderAsync(OtpDelivery delivery) + { + int Failure(int status, string stage, string reason) { - log.Complete(httpStatus); + OtpLog.RequestFailed( + _log, + status >= 500 ? LogLevel.Error : LogLevel.Warning, + stage, + reason, + status); + return status; } + + var config = AppConfig.Read(_env); + var provider = SelectProvider(config.ProviderName); + if (provider is null) + return Failure(400, "provider_selection", "unknown_provider"); + + OtpLog.ProviderSelected(_log, provider.Name, provider.AuthenticationMode); + var channel = (delivery.Channel ?? "sms").ToLowerInvariant(); + + if (channel is not ("sms" or "voice")) + return Failure(400, "provider_configuration", "unsupported_channel"); + + if (!string.IsNullOrEmpty(config.ProviderChannel) && config.ProviderChannel != channel) + return Failure(400, "provider_configuration", "channel_not_configured"); + if (!string.IsNullOrEmpty(config.ProviderAuthMode) && config.ProviderAuthMode != provider.AuthenticationMode) + return Failure(502, "provider_configuration", "authentication_mode_mismatch"); + + ProviderCredentials credential; + var credentialStarted = Stopwatch.StartNew(); + try + { + OtpLog.CredentialResolutionStarted( + _log, + provider.Name, + provider.AuthenticationMode == "oauth" + ? "managed_identity_client_assertion" + : "key_vault"); + credential = await _credentials.GetCredentialsAsync(provider, config); + } + catch + { + return Failure(502, "provider_credentials", "credential_unavailable"); + } + + var credentialUnavailable = credential.Mode switch + { + "apiKey" => string.IsNullOrEmpty(credential.Secret), + "oauth" => string.IsNullOrEmpty(credential.AccessToken), + _ => true, + }; + if (credentialUnavailable) + return Failure(502, "provider_credentials", "credential_unavailable"); + OtpLog.CredentialResolved( + _log, + provider.Name, + credentialStarted.ElapsedMilliseconds); + + var endpoint = config.ProviderEndpoint; + if (!PhoneProviderBase.IsHttpsEndpoint(endpoint)) + return Failure(502, "provider_configuration", "invalid_provider_endpoint"); + + using var client = _httpFactory.CreateClient(ProviderHttpClientName); + try + { + var result = await provider.SendOtpAsync( + channel, + endpoint!, + delivery, + credential, + _env, + client, + NormalizeProviderTimeoutMs(config.ProviderTimeoutMs), + _log).ConfigureAwait(false); + var status = PhoneProviderBase.ToEndpointHttpStatus(result); + if (status >= 400) + Failure( + status, + "provider_response", + result.FailureReason ?? "provider_rejected"); + return status; + } + catch (PhoneProviderBase.ProviderSendException exception) + { + return exception.StatusCode; + } + } + + // Replace this method body when provider choice depends on country, tenant, or other deployment policy. + private PhoneProviderBase? SelectProvider(string? configuredName) => + _providers.FirstOrDefault(provider => + string.Equals(provider.Name, configuredName, StringComparison.OrdinalIgnoreCase)); + + internal static int NormalizeProviderTimeoutMs(string? value) + { + var text = value?.Trim(); + if (string.IsNullOrEmpty(text)) return DefaultTimeoutMs; + + // Saturate while scanning every character: arbitrarily large decimal values are valid, + // but signs, exponents, hex, non-ASCII digits and invalid suffixes are not. + var timeout = 0; + foreach (var digit in text) + { + if (digit < '0' || digit > '9') return DefaultTimeoutMs; + timeout = Math.Min(MaxTimeoutMs, timeout * 10 + digit - '0'); + } + return timeout > 0 ? timeout : DefaultTimeoutMs; + } + + private sealed class InvalidRequestException( + int statusCode, + string error, + string? reason = null, + string? correlationId = null) : Exception(error) + { + public int StatusCode { get; } = statusCode; + public string Error { get; } = error; + public string? Reason { get; } = reason; + public string? CorrelationId { get; } = correlationId; } } diff --git a/dotnet/Program.cs b/dotnet/Program.cs index 046a7ad..d2d156b 100644 --- a/dotnet/Program.cs +++ b/dotnet/Program.cs @@ -11,21 +11,19 @@ builder.ConfigureFunctionsWebApplication(); // Application events use selected metadata; provider URLs must not appear in factory logs. -builder.Logging.AddFilter("System.Net.Http.HttpClient." + DispatchEngine.ProviderHttpClientName, LogLevel.None); -builder.Services.AddHttpClient(DispatchEngine.ProviderHttpClientName) +builder.Logging.AddFilter("System.Net.Http.HttpClient." + SendOtp.ProviderHttpClientName, LogLevel.None); +builder.Services.AddHttpClient(SendOtp.ProviderHttpClientName) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(services => services.GetRequiredService()); +builder.Services.AddSingleton(); builder.Build().Run(); diff --git a/dotnet/README.md b/dotnet/README.md index 0bbf2ac..2e46643 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1,14 +1,14 @@ # External Phone Provider Function: C# (.NET isolated worker) -Implements the shared [contract](../docs/CONTRACT.md) with one dispatch engine and one selected -provider per deployment. Target: .NET 8 isolated worker, Azure Functions v4. +Implements the shared [contract](../docs/CONTRACT.md) with one `SendOtp` request pipeline and one +selected provider per deployment. Target: .NET 8 isolated worker, Azure Functions v4. ## Setup and deployment 1. Follow [customer onboarding](../docs/ONBOARDING.md). Set `EPP_PROVIDER_NAME` to the selected - adapter's registered manifest id (`` is only a placeholder). -2. Consult the selected adapter and its manifest in [Src/Providers/](Src/Providers/) for required - credentials and options. Store credentials in Key Vault under the declared secret names, grant + adapter's `Name` (`` is only a placeholder). +2. Consult the selected adapter in [Src/Providers/](Src/Providers/) for required credentials and + options. Store credentials in Key Vault under the provider's secret names, grant the Function's managed identity *Key Vault Secrets User*, and configure the matching endpoint/options. 3. Base private local settings on [../docs/local.settings.sample.json](../docs/local.settings.sample.json), replacing placeholders and selecting `FUNCTIONS_WORKER_RUNTIME=dotnet-isolated`. Put settings @@ -20,6 +20,11 @@ provider per deployment. Target: .NET 8 isolated worker, Azure Functions v4. endpoint-app `allowedAudiences` and a nonempty `allowedApplications` list for the authorized SAS caller. Do not exclude SendOtp. There is no backup application token validation; never expose the endpoint to the public internet with Easy Auth disabled or bypassed. + + `SendOtp` uses Azure Functions `[FromBody]` binding to create `EntraSendOtpPayload`. Malformed JSON, + invalid enum tokens and other deserialization failures are rejected by the Functions binding/runtime + before `SendOtp` runs. Those failures therefore do not produce application `ILogger` events or the + handler's custom error response body; platform diagnostics and responses apply instead. 4. Build [dotnet.csproj](dotnet.csproj), run the offline xUnit suites in [tests/Epp.Otp.Tests.csproj](tests/Epp.Otp.Tests.csproj), and start the local Functions host from this folder. Core Tools has no Easy Auth: bind only to loopback, with no tunnels or public forwarding. @@ -49,7 +54,7 @@ For live delivery, add `EPP_PROVIDER_NAME`, the complete selected `EPP_PROVIDER_ matching provider authentication settings to `Values`. Add `EPP_PROVIDER_ACCOUNT_NAME` and adapter-specific options only when required. Optional `EPP_PROVIDER_TIMEOUT_MS` is a string such as `"1500"`. Replace placeholders; store provider credentials -under the adapter manifest's Key Vault secret names, not in local settings. See the +under the adapter's Key Vault secret names, not in local settings. See the [complete variable table](../README.md#configure-environment-variables). Core Tools loads `Values` into environment variables. [AppConfig.Read](Src/AppConfig.cs) reads them @@ -68,9 +73,10 @@ or the offline tests' injected environment and secret resolver for local develop `POST /api/SendOtp` uses the same request and trust boundaries as the other runtimes. Incoming `mode`, `channel`, `ttlSeconds` and `tenantId` are request data, not deployment authentication settings. -Easy Auth authenticates and authorizes the caller before the anonymous handler validates the envelope +Easy Auth authenticates and authorizes the caller before the anonymous handler validates the payload and decrypts the JWE. Incoming `Authorization` is not parsed or echoed by the handler. JWE does not authenticate SAS: anyone with the public key can encrypt a request, and a fixed nonce is not authentication. +Requests that bind successfully retain the application validation, PII-safe logging and response lifecycle. Use incoming `mode: 2` or `mode: "evaluation"` as the generic shutter for every provider: platform authentication on Azure, handler validation and decryption run, but provider lookup, provider Key Vault @@ -90,27 +96,43 @@ six-digit numeric run that is not part of a longer number and repeats the comple ## Source -The hosted service selects `ApiKeyCache` or `AccessTokenCache` from the provider manifest's auth mode. -Only the selected cache starts: API keys use Key Vault and framework `MemoryCache`; access tokens -use the MI/Entra SDKs without Key Vault. One periodic timer polls every 30 seconds. Configuration -changes require restart. Each shared acquisition owns -its cancellation budget; a waiter cannot cancel another request's retrieval. Disposal stops refresh -and prevents late publication. See the [refresh contract](../docs/CONTRACT.md#credential-caching-and-refresh) -for expiry, sanitized failure logs and cold-start limits. Evaluation remains independent. +`SendOtp` validates and decrypts the request, selects the configured provider in its private +`SelectProvider` method, resolves that provider's credentials, builds and sends the common bounded HTTP +request, asks the provider to deserialize its typed response DTO and return the final outcome, then maps that +outcome to the endpoint HTTP status. Providers +return standard `HttpRequestMessage` instances with typed `JsonContent`; the shared transport sends those +messages directly with redirects disabled, `ResponseHeadersRead`, and the bounded timeout. Each provider maps +its own normalized response status to the final outcome and reports whether that status was recognized. The +default method selects by `EPP_PROVIDER_NAME`; replace only its body if deployment policy later needs country, +tenant or other request-aware selection. No router or routing configuration abstraction is required. + +`CredentialTokenService` is the hosted startup warmer and runtime credential cache. +It asks the selected provider for credentials at startup and on cache misses. +Each provider owns credential acquisition and its secret names. The service stores +the result in .NET `MemoryCache` until the credential's absolute expiry; the next request fetches a +replacement. There is no polling timer or separate cache implementation. Each shared acquisition owns +its cancellation budget, so a waiter cannot cancel another request's retrieval. Evaluation remains +independent. | Source | Purpose | |---|---| -| [Program.cs](Program.cs) | Host and adapter registration | -| [Functions/SendOtp.cs](Functions/SendOtp.cs) | HTTP handler | +| [Program.cs](Program.cs) | Host, provider, credential cache and HTTP client registration | +| [Functions/SendOtp.cs](Functions/SendOtp.cs) | HTTP handler, provider selection, delivery orchestration and common HTTP transport | | [Src/AppConfig.cs](Src/AppConfig.cs) | Shared deployment settings | -| [Src/DispatchEngine.cs](Src/DispatchEngine.cs) | Envelope/JWE handling and dispatch | -| [Src/ProviderCredentials.cs](Src/ProviderCredentials.cs) | `ApiKeyCache`, `AccessTokenCache` and their shared refresh coordinator | -| [Src/CredentialRefreshService.cs](Src/CredentialRefreshService.cs) | Per-worker startup and shutdown integration | -| [Src/RequestLog.cs](Src/RequestLog.cs) | Request-scoped [service events and summaries](../docs/CONTRACT.md#application-logs) with explicit ID sources | -| [Src/ProviderRegistry.cs](Src/ProviderRegistry.cs), [Src/IProviderAdapter.cs](Src/IProviderAdapter.cs) | Adapter lookup and contract | -| [Src/Providers/](Src/Providers/) | Adapter manifests and API-specific implementations | -| [Src/SecretResolver.cs](Src/SecretResolver.cs) | Key Vault transport; `ISecretResolver.ResolveAsync` accepts cancellation and `ApiKeyCache` owns the bundle | -| [Src/OutcomeMapper.cs](Src/OutcomeMapper.cs), [Src/Models.cs](Src/Models.cs) | Outcomes and shared records | - -Implement `IProviderAdapter` and register it in [Program.cs](Program.cs) without adding provider-specific -branches to the shared pipeline. See [production limitations](../docs/CONTRACT.md#production-limitations) before production use. +| [Src/EntraSendOtpPayload.cs](Src/EntraSendOtpPayload.cs) | Bound request model, strict channel/mode converters and semantic validation | +| [Src/JweDeliveryContext.cs](Src/JweDeliveryContext.cs) | Pinned JWE decryption and decrypted delivery context | +| [Src/CredentialTokenService.cs](Src/CredentialTokenService.cs) | Provider-supplied retrieval, one expiring `MemoryCache` value and startup warmup | +| [Src/OtpLog.cs](Src/OtpLog.cs) | Source-generated, strongly typed [structured logging events](../docs/CONTRACT.md#application-logs) | +| [Src/PhoneProviderBase.cs](Src/PhoneProviderBase.cs) | Provider extension contract and shared typed JSON/HTTP transport | +| [Src/Providers/](Src/Providers/) | Provider identity, credential delegation and API-specific request/response protocols | +| [Src/SecretResolver.cs](Src/SecretResolver.cs) | Key Vault transport; `ISecretResolver.ResolveAsync` accepts cancellation | +| [Src/Models.cs](Src/Models.cs) | Outcomes and shared records | + +Derive from `PhoneProviderBase`, register the provider in [Program.cs](Program.cs), and give it the configured +name used by the simple `SelectProvider` policy in `SendOtp`. Providers own authentication declaration, +credential resolution, request construction and private typed response DTOs. Their public `SendOtpAsync` +method delegates to the base `SendJsonAsync` transport and maps the typed response into +`ProviderResult`; provider mapping code never handles `HttpResponseMessage` or raw JSON DOM types. +Provider-specific response semantics stay in each provider; no shared response-mapping +registry is used. Common async HTTP transport remains in `SendOtp`. See +[production limitations](../docs/CONTRACT.md#production-limitations) before production use. diff --git a/dotnet/Src/AppConfig.cs b/dotnet/Src/AppConfig.cs index 2713864..fddca29 100644 --- a/dotnet/Src/AppConfig.cs +++ b/dotnet/Src/AppConfig.cs @@ -12,7 +12,7 @@ public sealed class AppConfig public string? ProviderScope { get; init; } public string? OutboundClientId { get; init; } public string? OutboundManagedIdentityClientId { get; init; } - // Keep the raw value; DispatchEngine owns timeout normalization. + // Keep the raw value; SendOtp owns timeout normalization. public string? ProviderTimeoutMs { get; init; } public static AppConfig Read(IEnv env) => new() diff --git a/dotnet/Src/CredentialRefreshService.cs b/dotnet/Src/CredentialRefreshService.cs deleted file mode 100644 index 6d49ee7..0000000 --- a/dotnet/Src/CredentialRefreshService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.Extensions.Hosting; - -namespace Epp.Otp; - -internal sealed class CredentialRefreshService(DispatchEngine engine) : IHostedService -{ - public Task StartAsync(CancellationToken cancellationToken) => engine.StartCredentialRefreshAsync(cancellationToken); - - public Task StopAsync(CancellationToken cancellationToken) - { - engine.Dispose(); - return Task.CompletedTask; - } -} diff --git a/dotnet/Src/CredentialTokenService.cs b/dotnet/Src/CredentialTokenService.cs new file mode 100644 index 0000000..21c2552 --- /dev/null +++ b/dotnet/Src/CredentialTokenService.cs @@ -0,0 +1,105 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Epp.Otp; + +public sealed class CredentialTokenService : IHostedService, IDisposable +{ + internal static readonly TimeSpan AcquisitionTimeout = TimeSpan.FromSeconds(2.5); + + private readonly IReadOnlyList _providers; + private readonly IEnv? _env; + private readonly ILogger _log; + private readonly MemoryCache _cache = new(new MemoryCacheOptions()); + private bool _disposed; + + public CredentialTokenService( + IEnumerable providers, + IEnv env, + ILoggerFactory loggerFactory) + : this(providers, env, loggerFactory.CreateLogger("Epp.Otp.DispatchEngine")) + { + } + + internal CredentialTokenService( + IEnumerable? providers = null, + IEnv? env = null, + ILogger? log = null) + { + _providers = providers?.ToArray() ?? []; + _env = env; + _log = log ?? NullLogger.Instance; + } + + public async Task GetCredentialsAsync( + PhoneProviderBase provider, + AppConfig config, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + try + { + var value = await _cache.GetOrCreateAsync(provider.Name, async entry => + { + using var acquisition = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + acquisition.CancelAfter(AcquisitionTimeout); + var credentials = await provider.FetchCredentialsAsync( + config, acquisition.Token).ConfigureAwait(false); + if (credentials.ExpiresOn <= DateTimeOffset.UtcNow) + throw Unavailable(); + entry.AbsoluteExpiration = credentials.ExpiresOn; + return credentials; + }).ConfigureAwait(false); + return value ?? throw Unavailable(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + ReportFailure(provider.Name); + throw Unavailable(); + } + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + if (_env is null) return; + var config = AppConfig.Read(_env); + if (string.IsNullOrWhiteSpace(config.ProviderName)) return; + var provider = _providers.FirstOrDefault(candidate => + string.Equals(candidate.Name, config.ProviderName, StringComparison.OrdinalIgnoreCase)); + if (provider is null || (!string.IsNullOrEmpty(config.ProviderAuthMode) + && config.ProviderAuthMode != provider.AuthenticationMode)) + { + ReportFailure("configuration"); + return; + } + try + { + await GetCredentialsAsync(provider, config, cancellationToken).ConfigureAwait(false); + } + catch + { + ReportFailure("initialization"); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private void ReportFailure(string kind) + => OtpLog.CredentialRefreshFailed(_log, kind); + + internal static InvalidOperationException Unavailable() => + new("provider credential unavailable"); + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _cache.Dispose(); + } +} diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs deleted file mode 100644 index 9eaf53e..0000000 --- a/dotnet/Src/DispatchEngine.cs +++ /dev/null @@ -1,446 +0,0 @@ -using Azure.Core; -using Azure.Identity; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Logging; - -namespace Epp.Otp; - -public sealed record Envelope( - string? Type, - string? TenantId, - string? CorrelationId, - int Channel, - int Mode, - int? TtlSeconds, - string EncryptedDeliveryContext); - -public static class EnvelopeParser -{ - public const string EnvelopeType = "microsoft.mfa.otpDeliver.v1"; - public const int ModeLive = 1; - public const int ModeEvaluation = 2; - - private static readonly Dictionary ChannelByCode = new() { [1] = "sms", [2] = "voice" }; - private static readonly Dictionary ChannelByName = new(StringComparer.OrdinalIgnoreCase) { ["sms"] = 1, ["voice"] = 2 }; - private static readonly Dictionary ModeByName = new(StringComparer.OrdinalIgnoreCase) { ["live"] = ModeLive, ["evaluation"] = ModeEvaluation }; - - public static string? ChannelName(int code) => ChannelByCode.TryGetValue(code, out var name) ? name : null; - - public static async Task<(Envelope? Envelope, string? Error)> ParseAsync(Stream body, CancellationToken cancellationToken = default) - { - try - { - using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); - return Parse(document.RootElement); - } - catch (Exception error) when (error is JsonException or DecoderFallbackException - || error is InvalidOperationException { InnerException: DecoderFallbackException }) - { - return (null, "invalid JSON body"); - } - } - - public static (Envelope? Envelope, string? Error) Parse(JsonElement payload) - { - if (payload.ValueKind != JsonValueKind.Object) - return (null, "invalid envelope"); - - string? String(string name) => - payload.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; - int? Int(string name) => - payload.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null; - - int? Channel() - { - var code = Int("channel"); - if (code is not null) return ChannelByCode.ContainsKey(code.Value) ? code : null; - var name = String("channel"); - return name is not null && ChannelByName.TryGetValue(name, out var mapped) ? mapped : null; - } - int? Mode() - { - var code = Int("mode"); - if (code is not null) return code is ModeLive or ModeEvaluation ? code : null; - var name = String("mode"); - return name is not null && ModeByName.TryGetValue(name, out var mapped) ? mapped : null; - } - - if (String("type") != EnvelopeType) - return (null, "unsupported envelope type"); - - var encrypted = String("encryptedDeliveryContext"); - if (string.IsNullOrWhiteSpace(encrypted)) - return (null, "encryptedDeliveryContext is required"); - - var channel = Channel(); - if (channel is null) - return (null, "unsupported channel"); - - var mode = Mode(); - if (mode is null) - return (null, "unsupported mode"); - - int? ttlSeconds = null; - if (payload.TryGetProperty("ttlSeconds", out var ttl)) - { - if (ttl.ValueKind != JsonValueKind.Number || !ttl.TryGetInt32(out var seconds)) - return (null, "invalid ttlSeconds"); - if (seconds <= 0) - return (null, "ttlSeconds expired"); - ttlSeconds = seconds; - } - - return (new Envelope(String("type"), String("tenantId"), String("correlationId"), - channel.Value, mode.Value, ttlSeconds, encrypted), null); - } -} - -public sealed class DeliveryContext -{ - [JsonPropertyName("nonce")] public string? Nonce { get; set; } - [JsonPropertyName("phoneNumber")] public string? PhoneNumber { get; set; } - [JsonPropertyName("extension")] public string? Extension { get; set; } - [JsonPropertyName("locale")] public string? Locale { get; set; } - [JsonPropertyName("message")] public string? Message { get; set; } - [JsonPropertyName("riskContext")] public JsonElement? RiskContext { get; set; } - [JsonPropertyName("textToVoice")] public TextToVoice? TextToVoice { get; set; } - - [JsonIgnore] - public bool IsComplete => !string.IsNullOrWhiteSpace(Nonce) - && !string.IsNullOrWhiteSpace(PhoneNumber) - && !string.IsNullOrWhiteSpace(Message); - - public static DeliveryContext FromPayload(JsonElement payload) - { - if (payload.ValueKind != JsonValueKind.Object) return new(); - string? ReadString(string name) => payload.TryGetProperty(name, out var value) - && value.ValueKind == JsonValueKind.String ? value.GetString() : null; - TextToVoice? voice = null; - if (payload.TryGetProperty("textToVoice", out var speech) && speech.ValueKind == JsonValueKind.Object) - { - string? ReadVoiceString(string name) => speech.TryGetProperty(name, out var value) - && value.ValueKind == JsonValueKind.String ? value.GetString() : null; - voice = new TextToVoice(ReadVoiceString("beforePasswordText"), ReadVoiceString("password"), ReadVoiceString("language")); - } - return new() - { - Nonce = ReadString("nonce"), - PhoneNumber = ReadString("phoneNumber"), - Message = ReadString("message"), - Extension = ReadString("extension"), - Locale = ReadString("locale"), - RiskContext = payload.TryGetProperty("riskContext", out var risk) ? risk.Clone() : null, - TextToVoice = voice, - }; - } -} - -public sealed record JweResult(string? Kid, string? Alg, string? Enc, DeliveryContext Context); - -public interface IJweKeyProvider -{ - RSA GetPrivateKey(string? kid); -} - -public sealed class JweDecryptor -{ - private const int MaxJweLength = 16384; - private readonly IJweKeyProvider _keys; - - public JweDecryptor(IJweKeyProvider keys) => _keys = keys; - - public JweResult Decrypt(string compactJwe) - { - AssertWellFormed(compactJwe); - var headers = Jose.JWT.Headers(compactJwe); - var kid = headers.TryGetValue("kid", out var kidValue) ? kidValue?.ToString() : null; - 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. - var plaintext = Jose.JWT.Decrypt(compactJwe, rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); - using var payload = JsonDocument.Parse(plaintext); - var context = DeliveryContext.FromPayload(payload.RootElement); - return new JweResult(kid, alg, enc, context); - } - - private static void AssertWellFormed(string compactJwe) - { - // Reject oversized or malformed input before decoding or allocating buffers. - if (string.IsNullOrEmpty(compactJwe)) - throw new InvalidOperationException("malformed JWE"); - if (compactJwe.Length > MaxJweLength) - throw new InvalidOperationException("delivery context exceeds size limit"); - var segments = compactJwe.Split('.'); - if (segments.Length != 5 || Array.Exists(segments, string.IsNullOrEmpty)) - throw new InvalidOperationException("malformed JWE: expected five non-empty segments"); - } -} - -public sealed class EnvJweKeyProvider : IJweKeyProvider -{ - private readonly IEnv _env; - private RSA? _cached; - private string? _cachedPem; - - public EnvJweKeyProvider(IEnv env) => _env = env; - - public RSA GetPrivateKey(string? kid) - { - var pem = AppConfig.Read(_env).DecryptionKeyPem; - if (string.IsNullOrEmpty(pem)) - throw new InvalidOperationException("private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)"); - - if (_cached is not null && _cachedPem == pem) return _cached; - - var rsa = RSA.Create(); - rsa.ImportFromPem(NormalizePem(pem)); - _cached = rsa; - _cachedPem = pem; - return rsa; - } - - // Base64 preserves PEM newlines in app settings; accept either form. - private static string NormalizePem(string value) => - value.Contains("-----BEGIN", StringComparison.Ordinal) - ? value - : Encoding.UTF8.GetString(Convert.FromBase64String(value.Trim())); -} - -public sealed class DispatchEngine : IDisposable -{ - public const string ProviderHttpClientName = "otp-provider"; - private const int DefaultTimeoutMs = 1500; - private const int MaxTimeoutMs = 2500; - private readonly ProviderRegistry _registry; - private readonly IHttpClientFactory _httpFactory; - private readonly IEnv _env; - private readonly ProviderCredentials _credentials; - - public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, - IEnv? env = null, ILogger? log = null) - : this(registry, secrets, httpFactory, env, - identity => new ManagedIdentityCredential(identity, OAuthOptions()), - (tenant, application, assertion) => new ClientAssertionCredential(tenant, application, assertion, OAuthOptions()), log) { } - - internal DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env, - Func createManagedIdentity, - Func>, TokenCredential> createOAuthCredential, - ILogger? log = null, TimeProvider? clock = null) - { - _registry = registry; - _httpFactory = httpFactory; - _env = env ?? new ProcessEnv(); - _credentials = new ProviderCredentials(secrets, createManagedIdentity, createOAuthCredential, log, clock); - } - - private static ClientAssertionCredentialOptions OAuthOptions() - { - var options = new ClientAssertionCredentialOptions { AuthorityHost = AzureAuthorityHosts.AzurePublicCloud }; - options.Retry.MaxRetries = 0; - options.Retry.NetworkTimeout = ProviderCredentials.AcquisitionTimeout; - options.Diagnostics.IsLoggingEnabled = false; - options.Diagnostics.IsLoggingContentEnabled = false; - return options; - } - - public async Task StartCredentialRefreshAsync(CancellationToken cancellation = default) - { - var config = AppConfig.Read(_env); - if (string.IsNullOrWhiteSpace(config.ProviderName)) return; - var adapter = _registry.Get(config.ProviderName); - if (adapter is null || (!string.IsNullOrEmpty(config.ProviderAuthMode) && config.ProviderAuthMode != adapter.Manifest.Auth.Mode)) - { - _credentials.ReportFailure("configuration"); - return; - } - try { await _credentials.ResolveAsync(adapter.Manifest.Auth, config, cancellation).ConfigureAwait(false); } - catch (Exception) { _credentials.ReportFailure("initialization"); } - } - - public void Dispose() => _credentials.Dispose(); - - public async Task DispatchAsync(DispatchRequest dispatch, string requestId, RequestLog? log = null) - { - DispatchResult Failure(int status, string stage, string reason, object body) - { - log?.Failure(stage, reason, status); - return new DispatchResult(status, body); - } - - var config = AppConfig.Read(_env); - var adapter = _registry.Get(config.ProviderName); - if (adapter is null) - return Failure(400, "provider_selection", "unknown_provider", - new { status = "error", reason = "unknown provider", requestId }); - - var manifest = adapter.Manifest; - log?.ProviderSelected(manifest); - var providerId = manifest.Id; - var channel = (dispatch.Channel ?? "sms").ToLowerInvariant(); - - if (!OutcomeMapper.DefaultChannels.Contains(channel)) - return Failure(400, "provider_configuration", "unsupported_channel", - new { status = "error", provider = providerId, reason = "unsupported channel", requestId }); - - if (channel == "voice" && manifest.RequiresTextToVoice && dispatch.TextToVoice?.IsComplete != true) - return Failure(400, "provider_request_build", "incomplete_voice_context", - FailBody(providerId, channel, "incomplete voice context", dispatch, requestId)); - - if (!string.IsNullOrEmpty(config.ProviderChannel) && config.ProviderChannel != channel) - return Failure(400, "provider_configuration", "channel_not_configured", - new { status = "error", provider = providerId, reason = "channel not configured", requestId }); - if (!string.IsNullOrEmpty(config.ProviderAuthMode) && config.ProviderAuthMode != manifest.Auth.Mode) - return Failure(502, "provider_configuration", "authentication_mode_mismatch", - FailBody(providerId, channel, "provider authentication mismatch", dispatch, requestId)); - - ProviderCredential credential; - try - { - log?.CredentialResolutionStarted(config); - credential = await ResolveCredentialAsync(manifest.Auth, config); - } - catch - { - return Failure(502, "provider_credentials", "credential_unavailable", - FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); - } - - var identityRequired = credential.Mode == "apiKey" && !string.IsNullOrEmpty(manifest.Auth.IdentityKeyVaultSecretName); - var credentialUnavailable = credential.Mode switch - { - "apiKey" => string.IsNullOrEmpty(credential.Secret) - || (identityRequired && string.IsNullOrEmpty(credential.Identity)), - "oauth" => string.IsNullOrEmpty(credential.AccessToken), - _ => true, - }; - if (credentialUnavailable) - return Failure(502, "provider_credentials", "credential_unavailable", - FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); - log?.CredentialResolved(); - - var endpoint = config.ProviderEndpoint; - if (!IsHttpsEndpoint(endpoint)) - return Failure(502, "provider_configuration", "invalid_provider_endpoint", - FailBody(providerId, channel, "provider endpoint invalid or not configured", dispatch, requestId)); - - var timeoutMs = NormalizeProviderTimeoutMs(config.ProviderTimeoutMs); - var stage = "provider_request_build"; - try - { - log?.Service("provider_request_build_started"); - var req = adapter.BuildRequest(channel, endpoint!, dispatch, credential, _env); - if (!IsHttpsEndpoint(req.Url)) - return Failure(502, "provider_request_build", "invalid_provider_request_url", - FailBody(providerId, channel, "provider request endpoint invalid", dispatch, requestId)); - log?.ProviderRequestBuilt(req.Method, req.Url); - - stage = "provider_transport"; - var (providerHttpStatus, success, body) = await SendAsync(req, timeoutMs, log); - stage = "provider_response"; - JsonElement json; - var validJson = true; - try { using var responseDocument = JsonDocument.Parse(body); json = responseDocument.RootElement.Clone(); } - catch (JsonException) - { - validJson = false; - log?.Service("provider_response_invalid_json", level: LogLevel.Warning); - using var emptyDocument = JsonDocument.Parse("{}"); - json = emptyDocument.RootElement.Clone(); - } - - var parsed = adapter.ParseResponse(providerHttpStatus, success, json); - var outcome = OutcomeMapper.ResolveOutcome(manifest, parsed); - var httpStatus = OutcomeMapper.ToHttpStatus(outcome, parsed.ProviderHttpStatus); - log?.ProviderResponseProcessed(manifest, parsed, outcome, httpStatus, validJson); - - return new DispatchResult(httpStatus, new - { - status = outcome == Outcome.Continue ? "accepted" : "failed", - outcome = outcome.ToString(), - provider = providerId, - channel, - messageId = dispatch.MessageId, - correlationId = dispatch.CorrelationId, - requestId, - }); - } - catch (OperationCanceledException) - { - return Failure(504, stage, "provider_timeout", - FailBody(providerId, channel, $"endpoint timeout after {timeoutMs}ms", dispatch, requestId)); - } - catch - { - var reason = stage switch - { - "provider_request_build" => "request_build_failed", - "provider_response" => "response_parse_failed", - _ => "provider_network_error", - }; - return Failure(502, stage, reason, FailBody(providerId, channel, "provider request failed", dispatch, requestId)); - } - } - - private Task ResolveCredentialAsync(AuthConfig auth, AppConfig config) => - _credentials.ResolveAsync(auth, config); - - internal static int NormalizeProviderTimeoutMs(string? value) - { - var text = value?.Trim(); - if (string.IsNullOrEmpty(text)) return DefaultTimeoutMs; - - // Saturate while scanning every character: arbitrarily large decimal values are valid, - // but signs, exponents, hex, non-ASCII digits and invalid suffixes are not. - var timeout = 0; - foreach (var digit in text) - { - if (digit < '0' || digit > '9') return DefaultTimeoutMs; - timeout = Math.Min(MaxTimeoutMs, timeout * 10 + digit - '0'); - } - return timeout > 0 ? timeout : DefaultTimeoutMs; - } - - internal static bool IsHttpsEndpoint(string? endpoint) => - Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) - && uri.Scheme == Uri.UriSchemeHttps - && !string.IsNullOrEmpty(uri.Host) - && uri.Port > 0 - && string.IsNullOrEmpty(uri.UserInfo) - && string.IsNullOrEmpty(uri.Fragment); - - private async Task<(int HttpStatus, bool Success, string Body)> SendAsync(ProviderHttpRequest req, int timeoutMs, RequestLog? log) - { - using var cts = new CancellationTokenSource(timeoutMs); - using var client = _httpFactory.CreateClient(ProviderHttpClientName); - using var message = new HttpRequestMessage(new HttpMethod(req.Method), req.Url) - { - Content = new StringContent(req.Body, Encoding.UTF8, req.Headers.TryGetValue("Content-Type", out var ct) ? ct : "application/json"), - }; - foreach (var (k, v) in req.Headers) - { - if (k.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) continue; - if (!message.Headers.TryAddWithoutValidation(k, v)) message.Content.Headers.TryAddWithoutValidation(k, v); - } - log?.ProviderRequestStarted(timeoutMs); - try - { - using var resp = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cts.Token); - log?.ProviderResponseReceived((int)resp.StatusCode); - using var stream = await resp.Content.ReadAsStreamAsync(cts.Token); - using var reader = new StreamReader(stream, Encoding.UTF8); - var text = await reader.ReadToEndAsync(cts.Token); - return ((int)resp.StatusCode, resp.IsSuccessStatusCode, text); - } - finally - { - log?.ProviderRequestFinished(); - } - } - - private static object FailBody(string provider, string channel, string reason, DispatchRequest d, string requestId) => - new { status = "failed", outcome = "Fail", provider, channel, reason, correlationId = d.CorrelationId, messageId = d.MessageId, requestId }; -} diff --git a/dotnet/Src/EntraSendOtpPayload.cs b/dotnet/Src/EntraSendOtpPayload.cs new file mode 100644 index 0000000..9b96cff --- /dev/null +++ b/dotnet/Src/EntraSendOtpPayload.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace Epp.Otp; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EntraOtpChannel +{ + Sms = 1, + Voice = 2, +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EntraOtpMode +{ + Live = 1, + Evaluation = 2, +} + +public sealed record EntraSendOtpPayload +{ + public const string SupportedType = "microsoft.mfa.otpDeliver.v1"; + + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("tenantId")] + public string? TenantId { get; init; } + + [JsonPropertyName("correlationId")] + public string? CorrelationId { get; init; } + + [JsonPropertyName("channel")] + public EntraOtpChannel Channel { get; init; } + + [JsonPropertyName("mode")] + public EntraOtpMode Mode { get; init; } + + [JsonPropertyName("ttlSeconds")] + public int? TtlSeconds { get; init; } + + [JsonPropertyName("encryptedDeliveryContext")] + public string? EncryptedDeliveryContext { get; init; } + + [JsonIgnore] + public string ChannelName => Channel == EntraOtpChannel.Voice ? "voice" : "sms"; + + [JsonIgnore] + public bool IsEvaluation => Mode == EntraOtpMode.Evaluation; + + public string? Validate() + { + if (Type != SupportedType) return "unsupported payload type"; + if (string.IsNullOrWhiteSpace(EncryptedDeliveryContext)) + return "encryptedDeliveryContext is required"; + if (Channel is not (EntraOtpChannel.Sms or EntraOtpChannel.Voice)) + return "unsupported channel"; + if (Mode is not (EntraOtpMode.Live or EntraOtpMode.Evaluation)) + return "unsupported mode"; + if (TtlSeconds <= 0) return "ttlSeconds expired"; + return null; + } +} diff --git a/dotnet/Src/IProviderAdapter.cs b/dotnet/Src/IProviderAdapter.cs deleted file mode 100644 index 1d4a7dc..0000000 --- a/dotnet/Src/IProviderAdapter.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json; - -namespace Epp.Otp; - -public interface IProviderAdapter -{ - ProviderManifest Manifest { get; } - - ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env); - - ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json); -} diff --git a/dotnet/Src/JweDeliveryContext.cs b/dotnet/Src/JweDeliveryContext.cs new file mode 100644 index 0000000..16bbd63 --- /dev/null +++ b/dotnet/Src/JweDeliveryContext.cs @@ -0,0 +1,80 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Epp.Otp; + +public sealed class DeliveryContext +{ + [JsonPropertyName("nonce")] public string? Nonce { get; set; } + [JsonPropertyName("phoneNumber")] public string? PhoneNumber { get; set; } + [JsonPropertyName("locale")] public string? Locale { get; set; } + [JsonPropertyName("message")] public string? Message { get; set; } + + [JsonIgnore] + public bool IsComplete => !string.IsNullOrWhiteSpace(Nonce) + && !string.IsNullOrWhiteSpace(PhoneNumber) + && !string.IsNullOrWhiteSpace(Message); +} + +public sealed record DecryptedPayload(string? KeyId, T Value); + +public sealed class JweDecryptor +{ + private const int MaxJweLength = 16384; + private readonly IEnv _env; + private RSA? _cachedKey; + private string? _cachedPem; + + public JweDecryptor(IEnv env) => _env = env; + + public DecryptedPayload Decrypt(string encryptedContent) + { + AssertWellFormed(encryptedContent); + var headers = Jose.JWT.Headers(encryptedContent); + var keyId = headers.TryGetValue("kid", out var value) ? value?.ToString() : null; + // Pin alg/enc so a tampered header can't downgrade the crypto. + var plaintext = Jose.JWT.Decrypt( + encryptedContent, + GetPrivateKey(), + Jose.JweAlgorithm.RSA_OAEP_256, + Jose.JweEncryption.A256GCM); + var payload = JsonSerializer.Deserialize(plaintext) + ?? throw new JsonException("decrypted payload is empty"); + return new DecryptedPayload(keyId, payload); + } + + private RSA GetPrivateKey() + { + var pem = AppConfig.Read(_env).DecryptionKeyPem; + if (string.IsNullOrEmpty(pem)) + throw new InvalidOperationException( + "private key unavailable (EPP_DECRYPTION_KEY_PEM is not set)"); + if (_cachedKey is not null && _cachedPem == pem) return _cachedKey; + + var key = RSA.Create(); + key.ImportFromPem(NormalizePem(pem)); + _cachedKey = key; + _cachedPem = pem; + return key; + } + + private static void AssertWellFormed(string encryptedContent) + { + // Reject oversized or malformed input before decoding or allocating buffers. + if (string.IsNullOrEmpty(encryptedContent)) + throw new InvalidOperationException("malformed JWE"); + if (encryptedContent.Length > MaxJweLength) + throw new InvalidOperationException("delivery context exceeds size limit"); + var segments = encryptedContent.Split('.'); + if (segments.Length != 5 || Array.Exists(segments, string.IsNullOrEmpty)) + throw new InvalidOperationException("malformed JWE: expected five non-empty segments"); + } + + // Base64 preserves PEM newlines in app settings; accept either form. + private static string NormalizePem(string value) => + value.Contains("-----BEGIN", StringComparison.Ordinal) + ? value + : Encoding.UTF8.GetString(Convert.FromBase64String(value.Trim())); +} diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index 9eacb32..8664da0 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -2,7 +2,7 @@ namespace Epp.Otp; -public enum Outcome { Continue, Fail, Block, StepUp } +public enum Outcome { Continue, Fail, Block } public sealed record EndpointSuccessResponse( [property: JsonPropertyName("nonce")] string Nonce, @@ -18,53 +18,37 @@ public sealed record EndpointErrorResponse( [property: JsonPropertyName("reason"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Reason = null, [property: JsonPropertyName("correlationId"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? CorrelationId = null); -public sealed record DispatchRequest( - string Destination, +public sealed record OtpDelivery( + string PhoneNumber, string? Message, string Channel, string MessageId, string? CorrelationId, - string? Locale, - TextToVoice? TextToVoice = null); - -public sealed record TextToVoice( - [property: JsonPropertyName("beforePasswordText")] string? BeforePasswordText, - [property: JsonPropertyName("password")] string? Password, - [property: JsonPropertyName("language")] string? Language) -{ - [JsonIgnore] - public bool IsComplete => BeforePasswordText is not null - && !string.IsNullOrWhiteSpace(Password) && !string.IsNullOrWhiteSpace(Language); - - public override string ToString() => nameof(TextToVoice); -} - -public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, - [property: JsonIgnore] string? AccessToken = null) + string? Locale); + +public sealed record ProviderCredentials( + string Mode, + string? Secret = null, + string? Identity = null, + [property: JsonIgnore] string? AccessToken = null, + [property: JsonIgnore] DateTimeOffset ExpiresOn = default) { - public override string ToString() => nameof(ProviderCredential); + public override string ToString() => nameof(ProviderCredentials); } -public sealed record ProviderHttpRequest(string Url, string Method, Dictionary Headers, string Body); - -public sealed record ParsedResponse( - bool Success, +public sealed record ProviderResult( + Outcome Outcome, + bool StatusRecognized, int ProviderHttpStatus, string? ProviderMessageId = null, string? ProviderStatusName = null, string? ProviderStatusCode = null, string? ProviderStatusDescription = null) { - public override string ToString() => nameof(ParsedResponse); + public string? FailureReason { get; init; } + public override string ToString() => nameof(ProviderResult); } -public sealed record AuthConfig(string Mode, string? KeyVaultSecretName = null, string? IdentityKeyVaultSecretName = null); - -public sealed record ProviderManifest(string Id, AuthConfig Auth, IReadOnlyDictionary ResponseMapping, - bool RequiresTextToVoice = false); - -public sealed record DispatchResult(int HttpStatus, object Body); - public interface IEnv { string? Get(string key); } public sealed class ProcessEnv : IEnv diff --git a/dotnet/Src/OtpLog.cs b/dotnet/Src/OtpLog.cs new file mode 100644 index 0000000..45c2b28 --- /dev/null +++ b/dotnet/Src/OtpLog.cs @@ -0,0 +1,109 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Epp.Otp; + +internal static partial class OtpLog +{ + private static readonly Regex IdentifierPattern = new( + @"\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z", + RegexOptions.CultureInvariant); + + internal static string? SafeIdentifier(string? value) => + value is { Length: <= 128 } && IdentifierPattern.IsMatch(value) ? value : null; + + [LoggerMessage(EventId = 1000, EventName = "request_received", Level = LogLevel.Information, + Message = "OTP request received")] + internal static partial void RequestReceived(ILogger logger); + + [LoggerMessage(EventId = 1001, EventName = "payload_validated", Level = LogLevel.Information, + Message = "Payload validated: type {PayloadType}, channel {Channel}, evaluation {Evaluation}, TTL {TtlSeconds}")] + internal static partial void PayloadValidated( + ILogger logger, string? payloadType, string? channel, bool evaluation, int? ttlSeconds); + + [LoggerMessage(EventId = 1002, EventName = "delivery_context_decrypted", Level = LogLevel.Information, + Message = "Delivery context decrypted")] + internal static partial void DeliveryContextDecrypted(ILogger logger); + + [LoggerMessage(EventId = 1003, EventName = "encryption_key_id_mismatch", Level = LogLevel.Warning, + Message = "Encrypted payload key identifier did not match the configured identifier")] + internal static partial void EncryptionKeyIdMismatch(ILogger logger); + + [LoggerMessage(EventId = 1004, EventName = "evaluation_completed", Level = LogLevel.Information, + Message = "Evaluation request completed")] + internal static partial void EvaluationCompleted(ILogger logger); + + [LoggerMessage(EventId = 1100, EventName = "provider_selected", Level = LogLevel.Information, + Message = "Provider {ProviderName} selected with authentication mode {AuthenticationMode}")] + internal static partial void ProviderSelected( + ILogger logger, string providerName, string authenticationMode); + + [LoggerMessage(EventId = 1101, EventName = "provider_credential_resolution_started", Level = LogLevel.Information, + Message = "Resolving {CredentialSource} credentials for provider {ProviderName}")] + internal static partial void CredentialResolutionStarted( + ILogger logger, string providerName, string credentialSource); + + [LoggerMessage(EventId = 1102, EventName = "provider_credential_resolved", Level = LogLevel.Information, + Message = "Resolved credentials for provider {ProviderName} in {ElapsedMs} ms")] + internal static partial void CredentialResolved( + ILogger logger, string providerName, long elapsedMs); + + [LoggerMessage(EventId = 1200, EventName = "provider_request_build_started", Level = LogLevel.Information, + Message = "Building provider request")] + internal static partial void ProviderRequestBuildStarted(ILogger logger); + + [LoggerMessage(EventId = 1201, EventName = "provider_request_built", Level = LogLevel.Information, + Message = "Built provider request: {HttpMethod} {ProviderEndpoint}; redirects allowed: false")] + internal static partial void ProviderRequestBuilt( + ILogger logger, string httpMethod, string providerEndpoint); + + [LoggerMessage(EventId = 1202, EventName = "provider_request_started", Level = LogLevel.Information, + Message = "Sending provider request with timeout {TimeoutMs} ms")] + internal static partial void ProviderRequestStarted(ILogger logger, int timeoutMs); + + [LoggerMessage(EventId = 1203, EventName = "provider_response_received", Level = LogLevel.Information, + Message = "Provider returned HTTP status {ProviderHttpStatus}")] + internal static partial void ProviderResponseReceived(ILogger logger, int providerHttpStatus); + + [LoggerMessage(EventId = 1204, EventName = "provider_response_invalid_json", Level = LogLevel.Warning, + Message = "Provider response was not valid JSON")] + internal static partial void ProviderResponseInvalidJson(ILogger logger); + + [LoggerMessage(EventId = 1205, EventName = "provider_response_processed", + Message = "Processed provider response: HTTP {ProviderHttpStatus}, status {ProviderStatus}, outcome {ProviderOutcome}, failure {FailureReason}, elapsed {ElapsedMs} ms")] + internal static partial void ProviderResponseProcessed( + ILogger logger, + LogLevel level, + int providerHttpStatus, + string providerStatus, + string providerOutcome, + string? failureReason, + long elapsedMs); + + [LoggerMessage(EventId = 1300, EventName = "request_failed", + Message = "Request failed during {FailureStage}: {FailureReason}; HTTP status {HttpStatus}")] + internal static partial void RequestFailed( + ILogger logger, + LogLevel level, + string failureStage, + string failureReason, + int httpStatus); + + [LoggerMessage(EventId = 1301, EventName = "unexpected_error", Level = LogLevel.Error, + Message = "Unexpected request failure")] + internal static partial void UnexpectedError(ILogger logger); + + [LoggerMessage(EventId = 1400, EventName = "response_prepared", Level = LogLevel.Information, + Message = "Response prepared: HTTP {HttpStatus}, contains nonce {ContainsNonce}, contains correlation ID {ContainsCorrelationId}")] + internal static partial void ResponsePrepared( + ILogger logger, int httpStatus, bool containsNonce, bool containsCorrelationId); + + [LoggerMessage(EventId = 1401, EventName = "request_completed", Level = LogLevel.Information, + Message = "Request completed: HTTP {HttpStatus}, result {Result}, elapsed {ElapsedMs} ms")] + internal static partial void RequestCompleted( + ILogger logger, int httpStatus, string result, long elapsedMs); + + [LoggerMessage(EventId = 1500, EventName = "credential_refresh_failed", Level = LogLevel.Warning, + Message = "Credential refresh failed for {CacheKind}: credential unavailable")] + internal static partial void CredentialRefreshFailed(ILogger logger, string cacheKind); +} diff --git a/dotnet/Src/OutcomeMapper.cs b/dotnet/Src/OutcomeMapper.cs deleted file mode 100644 index 95c322d..0000000 --- a/dotnet/Src/OutcomeMapper.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Epp.Otp; - -// Maps a provider status to an outcome, then to an HTTP status. Fail-closed: unknown status is Fail. -public static class OutcomeMapper -{ - public static readonly string[] DefaultChannels = { "sms", "voice" }; - - public static Outcome ResolveOutcome(ProviderManifest manifest, ParsedResponse parsed) - { - var key = parsed.ProviderStatusName ?? parsed.ProviderStatusCode; - Outcome outcome; - if (!string.IsNullOrEmpty(key)) - { - outcome = manifest.ResponseMapping.TryGetValue(key, out var mapped) ? mapped - : manifest.ResponseMapping.TryGetValue("default", out var defaultOutcome) ? defaultOutcome : Outcome.Fail; - } - else - { - outcome = parsed.Success ? Outcome.Continue - : manifest.ResponseMapping.TryGetValue("default", out var fallbackOutcome) ? fallbackOutcome : Outcome.Fail; - } - // A success-shaped body cannot turn a failed HTTP request into an acknowledgement. - return outcome == Outcome.Continue && !parsed.Success ? Outcome.Fail : outcome; - } - - public static int ToHttpStatus(Outcome outcome, int providerHttpStatus) => outcome switch - { - Outcome.Continue => 200, - Outcome.Block => 403, - Outcome.StepUp => 409, - Outcome.Fail when providerHttpStatus == 429 => 429, - Outcome.Fail when providerHttpStatus is 401 or 403 => 401, - Outcome.Fail when providerHttpStatus >= 400 && providerHttpStatus < 500 => 400, - _ => 502, - }; -} diff --git a/dotnet/Src/PhoneProviderBase.cs b/dotnet/Src/PhoneProviderBase.cs new file mode 100644 index 0000000..dcc5281 --- /dev/null +++ b/dotnet/Src/PhoneProviderBase.cs @@ -0,0 +1,198 @@ +using System.Net; +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Epp.Otp; + +public abstract class PhoneProviderBase +{ + public abstract string Name { get; } + public abstract string AuthenticationMode { get; } + + public abstract Task SendOtpAsync( + string channel, + string endpoint, + OtpDelivery delivery, + ProviderCredentials credentials, + IEnv env, + HttpClient client, + int timeoutMs, + ILogger? logger = null); + + public abstract Task FetchCredentialsAsync( + AppConfig config, + CancellationToken cancellationToken = default); + + protected async Task SendJsonAsync( + Func createRequest, + Func mapResponse, + HttpClient client, + int timeoutMs, + ILogger? logger) + { + ProviderSendException Failure(int status, string stage, string reason) + { + if (logger is not null) + OtpLog.RequestFailed( + logger, + status >= 500 ? LogLevel.Error : LogLevel.Warning, + stage, + reason, + status); + return new ProviderSendException(status); + } + + var stage = "provider_request_build"; + try + { + if (logger is not null) OtpLog.ProviderRequestBuildStarted(logger); + using var request = createRequest(); + if (request.RequestUri is null || !IsHttpsEndpoint(request.RequestUri.AbsoluteUri)) + throw Failure(502, stage, "invalid_provider_request_url"); + if (logger is not null) + { + var method = NormalizeHttpMethod(request.Method); + var endpoint = SanitizeEndpoint(request.RequestUri); + OtpLog.ProviderRequestBuilt(logger, method, endpoint); + } + + stage = "provider_transport"; + ProviderResult? result = null; + var validJson = true; + using var cts = new CancellationTokenSource(timeoutMs); + var providerStarted = Stopwatch.StartNew(); + if (logger is not null) OtpLog.ProviderRequestStarted(logger, timeoutMs); + try + { + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cts.Token).ConfigureAwait(false); + if (logger is not null) + OtpLog.ProviderResponseReceived(logger, (int)response.StatusCode); + var bytes = await response.Content.ReadAsByteArrayAsync(cts.Token).ConfigureAwait(false); + try + { + var body = JsonSerializer.Deserialize( + bytes, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + result = mapResponse(body, response.StatusCode); + } + catch (JsonException) + { + try + { + using var _ = JsonDocument.Parse(bytes); + } + catch (JsonException) + { + result = new ProviderResult( + Outcome.Fail, + false, + (int)response.StatusCode) + { + FailureReason = "invalid_provider_json", + }; + validJson = false; + } + + if (validJson) + throw Failure(502, "provider_response", "response_parse_failed"); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + throw Failure(502, "provider_response", "response_parse_failed"); + } + } + finally { providerStarted.Stop(); } + + if (!validJson) + if (logger is not null) OtpLog.ProviderResponseInvalidJson(logger); + var httpStatus = ToEndpointHttpStatus(result!); + if (logger is not null) + { + var status = result!.StatusRecognized + ? result.ProviderStatusName ?? result.ProviderStatusCode ?? "unmapped" + : "unmapped"; + OtpLog.ProviderResponseProcessed( + logger, + httpStatus >= 500 ? LogLevel.Error + : httpStatus == 200 ? LogLevel.Information : LogLevel.Warning, + result.ProviderHttpStatus, + status, + result.Outcome.ToString(), + result.FailureReason, + providerStarted.ElapsedMilliseconds); + } + return result!; + } + catch (OperationCanceledException) + { + throw Failure(504, stage, "provider_timeout"); + } + catch (ProviderSendException) + { + throw; + } + catch + { + var reason = stage == "provider_request_build" + ? "request_build_failed" + : "provider_network_error"; + throw Failure(502, stage, reason); + } + } + + internal static bool IsHttpsEndpoint(string? endpoint) => + Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttps + && !string.IsNullOrEmpty(uri.Host) + && uri.Port > 0 + && string.IsNullOrEmpty(uri.UserInfo) + && string.IsNullOrEmpty(uri.Fragment); + + internal static string SanitizeEndpoint(Uri endpoint) => + endpoint.GetComponents(UriComponents.SchemeAndServer, UriFormat.UriEscaped) + + endpoint.AbsolutePath; + + private static string NormalizeHttpMethod(HttpMethod? method) + { + var value = method?.Method.ToUpperInvariant(); + return value is "GET" or "HEAD" or "POST" or "PUT" or "DELETE" + or "CONNECT" or "OPTIONS" or "TRACE" or "PATCH" ? value : "other"; + } + + internal static int ToEndpointHttpStatus(ProviderResult result) => result.Outcome switch + { + Outcome.Continue => 200, + Outcome.Block => 403, + Outcome.Fail when result.ProviderHttpStatus == 429 => 429, + Outcome.Fail when result.ProviderHttpStatus is 401 or 403 => 401, + Outcome.Fail when result.ProviderHttpStatus >= 400 && result.ProviderHttpStatus < 500 => 400, + _ => 502, + }; + + protected static string? ClassifyFailure( + HttpStatusCode httpStatus, + Outcome outcome, + bool statusRecognized) + { + if ((int)httpStatus is < 200 or >= 300) + return "provider_http_error"; + if (outcome != Outcome.Fail) + return null; + return statusRecognized + ? "provider_rejected" + : "unrecognized_provider_status"; + } + + internal sealed class ProviderSendException(int statusCode) : Exception + { + public int StatusCode { get; } = statusCode; + } +} diff --git a/dotnet/Src/ProviderCredentials.cs b/dotnet/Src/ProviderCredentials.cs deleted file mode 100644 index 374cfa0..0000000 --- a/dotnet/Src/ProviderCredentials.cs +++ /dev/null @@ -1,241 +0,0 @@ -using System.Text.Json; -using Azure.Core; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Internal; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using static Epp.Otp.ProviderCredentials; - -namespace Epp.Otp; - -internal interface ICredentialCache : IDisposable -{ - string Stage { get; } - ProviderCredential? Get(); - Task RefreshAsync(CancellationToken cancellation); -} - -internal sealed class ApiKeyCache(ISecretResolver secrets, AuthConfig auth, TimeProvider clock) : ICredentialCache -{ - private const string BundleKey = "bundle"; - private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5); - private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(4); - private readonly object _gate = new(); - // The fixed key bounds the cache; a size limit can reject replacement before removing the old entry. - private readonly MemoryCache _values = new(new MemoryCacheOptions { Clock = new CacheClock(clock) }); - private DateTimeOffset _refreshAt; - private bool _closed; - public string Stage => "key_vault"; - - private sealed class CacheClock(TimeProvider time) : ISystemClock - { - public DateTimeOffset UtcNow => time.GetUtcNow(); - } - - public ProviderCredential? Get() - { - lock (_gate) return _closed ? null : _values.Get(BundleKey); - } - public async Task RefreshAsync(CancellationToken cancellation) - { - lock (_gate) - { - if (_closed) throw Unavailable(); - if (Get() is not null && _refreshAt > clock.GetUtcNow()) return; - } - if (string.IsNullOrWhiteSpace(auth.KeyVaultSecretName)) throw Unavailable(); - var key = secrets.ResolveAsync(auth.KeyVaultSecretName, cancellation); - var identity = string.IsNullOrWhiteSpace(auth.IdentityKeyVaultSecretName) - ? Task.FromResult("") : secrets.ResolveAsync(auth.IdentityKeyVaultSecretName, cancellation); - await Task.WhenAll(key, identity).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(key.Result) || - (!string.IsNullOrWhiteSpace(auth.IdentityKeyVaultSecretName) && string.IsNullOrWhiteSpace(identity.Result))) throw Unavailable(); - lock (_gate) - { - cancellation.ThrowIfCancellationRequested(); - if (_closed) throw Unavailable(); - _values.Set(BundleKey, new ProviderCredential(ApiKeyMode, Secret: key.Result, Identity: identity.Result), - new MemoryCacheEntryOptions { AbsoluteExpiration = clock.GetUtcNow() + Ttl }); - _refreshAt = clock.GetUtcNow() + RefreshInterval; - } - } - public void Dispose() - { - lock (_gate) { _closed = true; _values.Dispose(); } - } -} - -internal sealed class AccessTokenCache : ICredentialCache -{ - private const string ExchangeScope = "api://AzureADTokenExchange/.default"; - private static readonly TimeSpan ExpirySkew = TimeSpan.FromSeconds(30); - private readonly object _gate = new(); - private readonly TokenCredential _identity, _credential; - private readonly string _scope; - private readonly TimeProvider _clock; - private AccessToken? _token; - private bool _closed; - public string Stage { get; private set; } = "provider_token"; - - internal AccessTokenCache(AppConfig config, Func createIdentity, - Func>, TokenCredential> createCredential, TimeProvider clock) - { - if (string.IsNullOrWhiteSpace(config.ProviderTenantId) || string.IsNullOrWhiteSpace(config.ProviderScope) - || string.IsNullOrWhiteSpace(config.OutboundClientId) || string.IsNullOrWhiteSpace(config.OutboundManagedIdentityClientId)) throw Unavailable(); - _clock = clock; - _scope = config.ProviderScope; - _identity = createIdentity(config.OutboundManagedIdentityClientId); - _credential = createCredential(config.ProviderTenantId, config.OutboundClientId, - async cancellation => (await Assertion(cancellation).ConfigureAwait(false)).Token); - } - public ProviderCredential? Get() - { - lock (_gate) return !_closed && _token is { } token && token.ExpiresOn > _clock.GetUtcNow() + ExpirySkew - ? new(OAuthMode, AccessToken: token.Token) : null; - } - private AccessToken Check(AccessToken token) - { - if (string.IsNullOrWhiteSpace(token.Token) || token.ExpiresOn <= _clock.GetUtcNow() + ExpirySkew) throw Unavailable(); - return token; - } - private async Task Assertion(CancellationToken cancellation) => - Check(await _identity.GetTokenAsync(new TokenRequestContext(new[] { ExchangeScope }), cancellation).ConfigureAwait(false)); - - public async Task RefreshAsync(CancellationToken cancellation) - { - lock (_gate) { if (_closed) throw Unavailable(); } - Stage = "managed_identity"; - await Assertion(cancellation).ConfigureAwait(false); - Stage = "provider_token"; - var token = Check(await _credential.GetTokenAsync(new TokenRequestContext(new[] { _scope }), cancellation).ConfigureAwait(false)); - lock (_gate) - { - cancellation.ThrowIfCancellationRequested(); - if (_closed) throw Unavailable(); - _token = token; - } - } - public void Dispose() - { - lock (_gate) { _closed = true; _token = null; } - } -} - -// One selected cache and one periodic refresh; configuration changes require a worker restart. -internal sealed class ProviderCredentials : IDisposable -{ - internal const string ApiKeyMode = "apiKey"; - internal const string OAuthMode = "oauth"; - internal static readonly TimeSpan AcquisitionTimeout = TimeSpan.FromSeconds(2.5); - private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); - private readonly object _gate = new(); - private readonly ISecretResolver _secrets; - private readonly Func _createIdentity; - private readonly Func>, TokenCredential> _createCredential; - private readonly ILogger _log; - private readonly TimeProvider _clock; - private ICredentialCache? _cache; - private Task? _pending; - private CancellationTokenSource? _acquisition; - private ITimer? _timer; - private DateTimeOffset _nextAttempt; - private bool _disposed; - - internal ProviderCredentials(ISecretResolver secrets, Func createIdentity, - Func>, TokenCredential> createCredential, - ILogger? log = null, TimeProvider? clock = null) - { - _secrets = secrets; - _createIdentity = createIdentity; - _createCredential = createCredential; - _log = log ?? NullLogger.Instance; - _clock = clock ?? TimeProvider.System; - } - internal void ReportFailure(string kind) - { - const string eventName = "credential_refresh_failed"; - var record = new Dictionary - { - ["logType"] = "service", ["eventName"] = eventName, - ["cacheKind"] = kind, ["failureReason"] = "credential_unavailable", - }; - _log.Log(LogLevel.Warning, new EventId(0, eventName), record, null, - static (state, _) => JsonSerializer.Serialize(state)); - } - internal Task ResolveAsync(AuthConfig auth, AppConfig config, CancellationToken cancellation = default) - { - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (_cache is null) - { - try - { - _cache = auth.Mode switch - { - ApiKeyMode => new ApiKeyCache(_secrets, auth, _clock), - OAuthMode => new AccessTokenCache(config, _createIdentity, _createCredential, _clock), - _ => throw Unavailable(), - }; - _timer = _clock.CreateTimer(_ => Tick(), null, PollInterval, PollInterval); - } - catch (Exception) { ReportFailure("configuration"); return Task.FromException(Unavailable()); } - } - var cached = _cache.Get(); - if (cached is not null) return Task.FromResult(cached); - var pending = Refresh(); - return cancellation.CanBeCanceled ? pending.WaitAsync(cancellation) : pending; - } - } - private void Tick() - { - lock (_gate) { if (!_disposed) _ = ObserveAsync(Refresh()); } - } - private static async Task ObserveAsync(Task task) => await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - private Task Refresh() - { - if (_pending is not null) return _pending; - if (_disposed || _cache is null || _nextAttempt > _clock.GetUtcNow()) - return Task.FromException(Unavailable()); - _nextAttempt = _clock.GetUtcNow() + PollInterval; - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pending = completion.Task; - var cancellation = _acquisition = new CancellationTokenSource(AcquisitionTimeout, _clock); - _ = RunAsync(_cache, completion, cancellation); - return completion.Task; - } - private async Task RunAsync(ICredentialCache cache, TaskCompletionSource completion, CancellationTokenSource cancellation) - { - ProviderCredential? value = null; - try - { - await cache.RefreshAsync(cancellation.Token).WaitAsync(cancellation.Token).ConfigureAwait(false); - value = cache.Get(); - } - catch (Exception) { /* Report only the sanitized failure below. */ } - lock (_gate) - { - if (_disposed || cancellation.IsCancellationRequested) value = null; - _pending = null; - _acquisition = null; - cancellation.Dispose(); - if (value is null) - { - if (!_disposed) ReportFailure(cache.Stage); - completion.TrySetException(Unavailable()); - } - else completion.TrySetResult(value); - } - } - internal static InvalidOperationException Unavailable() => new("provider credential unavailable"); - public void Dispose() - { - lock (_gate) - { - _disposed = true; - _timer?.Dispose(); - _acquisition?.Cancel(); - _cache?.Dispose(); - } - } -} diff --git a/dotnet/Src/ProviderRegistry.cs b/dotnet/Src/ProviderRegistry.cs deleted file mode 100644 index 0af6b71..0000000 --- a/dotnet/Src/ProviderRegistry.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Epp.Otp; - -public sealed class ProviderRegistry -{ - private readonly IReadOnlyDictionary _byId; - - public ProviderRegistry(IEnumerable adapters) - { - _byId = adapters.ToDictionary(a => a.Manifest.Id.ToLowerInvariant(), a => a); - } - - public IProviderAdapter? Get(string? id) - { - if (string.IsNullOrWhiteSpace(id)) return null; - return _byId.TryGetValue(id.ToLowerInvariant(), out var adapter) ? adapter : null; - } -} diff --git a/dotnet/Src/Providers/InfobipProvider.cs b/dotnet/Src/Providers/InfobipProvider.cs index 3fd5207..64556f2 100644 --- a/dotnet/Src/Providers/InfobipProvider.cs +++ b/dotnet/Src/Providers/InfobipProvider.cs @@ -1,82 +1,148 @@ -using System.Text.Json; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; namespace Epp.Otp.Providers; -public sealed class InfobipProvider : IProviderAdapter +public sealed class InfobipProvider : PhoneProviderBase { - public ProviderManifest Manifest { get; } = new( - Id: "infobip", - Auth: new AuthConfig("apiKey", KeyVaultSecretName: "infobip-api-key"), - ResponseMapping: new Dictionary - { - ["ACCEPTED"] = Outcome.Continue, - ["PENDING"] = Outcome.Continue, - ["DELIVERED"] = Outcome.Continue, - ["REJECTED"] = Outcome.Fail, - ["EXPIRED"] = Outcome.Fail, - ["UNDELIVERABLE"] = Outcome.Fail, - ["default"] = Outcome.Fail, - }); - - public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) + private readonly ISecretResolver? _secrets; + + public InfobipProvider(ISecretResolver? secrets = null) => _secrets = secrets; + + public override string Name => "infobip"; + public override string AuthenticationMode => "apiKey"; + + public override Task SendOtpAsync( + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials, + IEnv env, HttpClient client, int timeoutMs, ILogger? logger = null) => + SendJsonAsync( + () => CreateRequest(channel, endpoint, delivery, credentials, env), + MapResponse, + client, + timeoutMs, + logger); + + private static HttpRequestMessage CreateRequest( + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credential, IEnv env) { var senderId = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? "Verify"; - var headers = new Dictionary - { - ["Authorization"] = $"App {credential.Secret}", - ["Content-Type"] = "application/json", - ["Accept"] = "application/json", - }; - var messageId = dispatch.CorrelationId ?? dispatch.MessageId; + var messageId = delivery.CorrelationId ?? delivery.MessageId; + object body; + string url; if (channel == "voice") { - var voiceBody = new - { - messages = new[] - { - new - { - from = senderId, - destinations = new[] { new { to = dispatch.Destination, messageId } }, - text = dispatch.Message, - language = dispatch.Locale ?? "en", - voice = new { name = "Joanna", gender = "female" }, - }, - }, - }; - return new ProviderHttpRequest($"{endpoint}/tts/3/advanced", "POST", headers, JsonSerializer.Serialize(voiceBody)); + body = new VoiceRequest( + [ + new VoiceMessage( + senderId, + [new Destination(delivery.PhoneNumber, messageId)], + delivery.Message, + delivery.Locale ?? "en", + new Voice("Joanna", "female")) + ]); + url = $"{endpoint}/tts/3/advanced"; + } + else + { + body = new SmsRequest( + [ + new SmsMessage( + senderId, + [new Destination(delivery.PhoneNumber, messageId)], + new SmsContent(delivery.Message)) + ]); + url = $"{endpoint}/sms/3/messages"; } - var body = new + var request = new HttpRequestMessage(HttpMethod.Post, url) { - messages = new[] - { - new - { - sender = senderId, - destinations = new[] { new { to = dispatch.Destination, messageId } }, - content = new { text = dispatch.Message }, - }, - }, + Content = JsonContent.Create(body), }; - return new ProviderHttpRequest($"{endpoint}/sms/3/messages", "POST", headers, JsonSerializer.Serialize(body)); + request.Headers.TryAddWithoutValidation("Authorization", $"App {credential.Secret}"); + request.Headers.Accept.ParseAdd("application/json"); + return request; } - public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) + private static ProviderResult MapResponse(Response? payload, HttpStatusCode httpStatus) { - string? messageId = null, statusName = null, statusDesc = null; - if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("messages", out var messages) && messages.ValueKind == JsonValueKind.Array && messages.GetArrayLength() > 0) + var message = payload?.Messages?.FirstOrDefault(); + var status = message?.Status; + var statusName = (status?.GroupName ?? status?.Name)?.ToUpperInvariant(); + var (outcome, recognized) = MapStatus(statusName); + var finalOutcome = (int)httpStatus is >= 200 and < 300 ? outcome : Outcome.Fail; + return new ProviderResult( + finalOutcome, + recognized, + (int)httpStatus, + message?.MessageId, + statusName, + ProviderStatusDescription: status?.Description) { - var firstMessage = messages[0]; - if (firstMessage.TryGetProperty("messageId", out var messageIdElement)) messageId = messageIdElement.ToString(); - if (firstMessage.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Object) - { - if (status.TryGetProperty("groupName", out var groupName)) statusName = groupName.GetString()?.ToUpperInvariant(); - else if (status.TryGetProperty("name", out var name)) statusName = name.GetString()?.ToUpperInvariant(); - if (status.TryGetProperty("description", out var description)) statusDesc = description.GetString(); - } - } - return new ParsedResponse(ok, httpStatus, messageId, statusName, null, statusDesc); + FailureReason = ClassifyFailure(httpStatus, finalOutcome, recognized), + }; + } + + public override async Task FetchCredentialsAsync( + AppConfig config, CancellationToken cancellationToken = default) + { + if (_secrets is null) throw CredentialTokenService.Unavailable(); + var secret = await _secrets.ResolveAsync( + "infobip-api-key", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(secret)) throw CredentialTokenService.Unavailable(); + return new ProviderCredentials( + AuthenticationMode, + Secret: secret, + ExpiresOn: DateTimeOffset.UtcNow.AddMinutes(5)); } + + private static (Outcome Outcome, bool Recognized) MapStatus(string? status) => status switch + { + "ACCEPTED" or "PENDING" or "DELIVERED" => (Outcome.Continue, true), + "REJECTED" or "EXPIRED" or "UNDELIVERABLE" => (Outcome.Fail, true), + _ => (Outcome.Fail, false), + }; + + private sealed record SmsRequest( + [property: JsonPropertyName("messages")] IReadOnlyList Messages); + + private sealed record SmsMessage( + [property: JsonPropertyName("sender")] string Sender, + [property: JsonPropertyName("destinations")] IReadOnlyList Destinations, + [property: JsonPropertyName("content")] SmsContent Content); + + private sealed record SmsContent( + [property: JsonPropertyName("text")] string? Text); + + private sealed record VoiceRequest( + [property: JsonPropertyName("messages")] IReadOnlyList Messages); + + private sealed record VoiceMessage( + [property: JsonPropertyName("from")] string From, + [property: JsonPropertyName("destinations")] IReadOnlyList Destinations, + [property: JsonPropertyName("text")] string? Text, + [property: JsonPropertyName("language")] string Language, + [property: JsonPropertyName("voice")] Voice Voice); + + private sealed record Destination( + [property: JsonPropertyName("to")] string To, + [property: JsonPropertyName("messageId")] string MessageId); + + private sealed record Voice( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("gender")] string Gender); + + private sealed record Response( + [property: JsonPropertyName("messages")] IReadOnlyList? Messages); + + private sealed record ResponseMessage( + [property: JsonPropertyName("messageId")] string? MessageId, + [property: JsonPropertyName("status")] ResponseStatus? Status); + + private sealed record ResponseStatus( + [property: JsonPropertyName("groupName")] string? GroupName, + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("description")] string? Description); } diff --git a/dotnet/Src/Providers/SinchProvider.cs b/dotnet/Src/Providers/SinchProvider.cs index 673b42f..467e032 100644 --- a/dotnet/Src/Providers/SinchProvider.cs +++ b/dotnet/Src/Providers/SinchProvider.cs @@ -1,69 +1,136 @@ -using System.Text.Json; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; namespace Epp.Otp.Providers; -public sealed class SinchProvider : IProviderAdapter +public sealed class SinchProvider : PhoneProviderBase { - public ProviderManifest Manifest { get; } = new( - Id: "sinch", - Auth: new AuthConfig("apiKey", KeyVaultSecretName: "sinch-api-token"), - ResponseMapping: new Dictionary - { - ["Dispatched"] = Outcome.Continue, - ["Delivered"] = Outcome.Continue, - ["Queued"] = Outcome.Continue, - ["Failed"] = Outcome.Fail, - ["Rejected"] = Outcome.Fail, - ["default"] = Outcome.Fail, - }); - - public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) + private readonly ISecretResolver? _secrets; + + public SinchProvider(ISecretResolver? secrets = null) => _secrets = secrets; + + public override string Name => "sinch"; + public override string AuthenticationMode => "apiKey"; + + public override Task SendOtpAsync( + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials, + IEnv env, HttpClient client, int timeoutMs, ILogger? logger = null) => + SendJsonAsync( + () => CreateRequest(channel, endpoint, delivery, credentials, env), + MapResponse, + client, + timeoutMs, + logger); + + private static HttpRequestMessage CreateRequest( + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credential, IEnv env) { - var headers = new Dictionary - { - ["Authorization"] = $"Bearer {credential.Secret}", - ["Content-Type"] = "application/json", - ["Accept"] = "application/json", - }; - var reference = dispatch.CorrelationId ?? dispatch.MessageId; + var reference = delivery.CorrelationId ?? delivery.MessageId; + object body; + string url; if (channel == "voice") { var voiceBase = env.Get("SINCH_VOICE_ENDPOINT") ?? "https://calling.api.sinch.com"; - var voiceBody = new - { - method = "ttsCallout", - ttsCallout = new - { - destination = new { type = "number", endpoint = dispatch.Destination }, - text = dispatch.Message, - locale = dispatch.Locale ?? "en-US", - custom = reference, - }, - }; - return new ProviderHttpRequest($"{voiceBase}/calling/v1/callouts", "POST", headers, JsonSerializer.Serialize(voiceBody)); + body = new VoiceRequest( + "ttsCallout", + new TtsCallout( + new Destination("number", delivery.PhoneNumber), + delivery.Message, + delivery.Locale ?? "en-US", + reference)); + url = $"{voiceBase}/calling/v1/callouts"; + } + else + { + var servicePlanId = env.Get("SINCH_SERVICE_PLAN_ID") ?? string.Empty; + body = new SmsRequest( + env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? "Verify", + [delivery.PhoneNumber], + delivery.Message, + reference); + url = $"{endpoint}/xms/v1/{servicePlanId}/batches"; } - var servicePlanId = env.Get("SINCH_SERVICE_PLAN_ID") ?? string.Empty; - var body = new + var request = new HttpRequestMessage(HttpMethod.Post, url) { - from = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? "Verify", - to = new[] { dispatch.Destination }, - body = dispatch.Message, - client_reference = reference, + Content = JsonContent.Create(body), }; - return new ProviderHttpRequest($"{endpoint}/xms/v1/{servicePlanId}/batches", "POST", headers, JsonSerializer.Serialize(body)); + request.Headers.TryAddWithoutValidation("Authorization", "******"); + request.Headers.Accept.ParseAdd("application/json"); + return request; } - public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) + private static ProviderResult MapResponse(Response? payload, HttpStatusCode httpStatus) { - string? id = null, desc = null; - if (json.ValueKind == JsonValueKind.Object) + var id = payload?.Id ?? payload?.CallId; + var status = payload?.Status; + var successful = (int)httpStatus is >= 200 and < 300; + if (payload?.Status is null && successful && !string.IsNullOrWhiteSpace(id)) + status = "Dispatched"; + var (outcome, recognized) = MapStatus(status); + var hasMessageId = !string.IsNullOrWhiteSpace(id); + var finalOutcome = successful && hasMessageId ? outcome : Outcome.Fail; + var failureReason = successful && !hasMessageId + ? "missing_provider_message_id" + : ClassifyFailure(httpStatus, finalOutcome, recognized); + return new ProviderResult( + finalOutcome, + recognized, + (int)httpStatus, + id, + status, + ProviderStatusDescription: payload?.Text) { - if (json.TryGetProperty("id", out var idElement)) id = idElement.ToString(); - else if (json.TryGetProperty("callId", out var callIdElement)) id = callIdElement.ToString(); - if (json.TryGetProperty("text", out var textElement)) desc = textElement.GetString(); - } - return new ParsedResponse(ok, httpStatus, id, ok ? "Dispatched" : null, null, desc); + FailureReason = failureReason, + }; } + + public override async Task FetchCredentialsAsync( + AppConfig config, CancellationToken cancellationToken = default) + { + if (_secrets is null) throw CredentialTokenService.Unavailable(); + var secret = await _secrets.ResolveAsync( + "sinch-api-token", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(secret)) throw CredentialTokenService.Unavailable(); + return new ProviderCredentials( + AuthenticationMode, + Secret: secret, + ExpiresOn: DateTimeOffset.UtcNow.AddMinutes(5)); + } + + private static (Outcome Outcome, bool Recognized) MapStatus(string? status) => status switch + { + "Dispatched" or "Delivered" or "Queued" => (Outcome.Continue, true), + "Failed" or "Rejected" => (Outcome.Fail, true), + _ => (Outcome.Fail, false), + }; + + private sealed record VoiceRequest( + [property: JsonPropertyName("method")] string Method, + [property: JsonPropertyName("ttsCallout")] TtsCallout Callout); + + private sealed record TtsCallout( + [property: JsonPropertyName("destination")] Destination Destination, + [property: JsonPropertyName("text")] string? Text, + [property: JsonPropertyName("locale")] string Locale, + [property: JsonPropertyName("custom")] string Custom); + + private sealed record Destination( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("endpoint")] string Endpoint); + + private sealed record SmsRequest( + [property: JsonPropertyName("from")] string From, + [property: JsonPropertyName("to")] IReadOnlyList To, + [property: JsonPropertyName("body")] string? Body, + [property: JsonPropertyName("client_reference")] string ClientReference); + + private sealed record Response( + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("callId")] string? CallId, + [property: JsonPropertyName("text")] string? Text, + [property: JsonPropertyName("status")] string? Status); } diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index 335ed1e..5c040cf 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -1,87 +1,286 @@ +using Azure.Core; +using Azure.Identity; +using System.Globalization; +using System.Net; +using System.Net.Http.Json; using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; namespace Epp.Otp.Providers; -public sealed class SopranoProvider : IProviderAdapter +public sealed class SopranoProvider : PhoneProviderBase { private const string DefaultVoiceLanguage = "en-US"; private const int VoiceGender = 1; private const int VoiceLoop = 2; + private static readonly TimeSpan ExpirySkew = TimeSpan.FromSeconds(30); + private readonly object _credentialGate = new(); + private readonly Func _createIdentity; + private readonly Func>, TokenCredential> _createCredential; + private TokenCredential? _identity; + private TokenCredential? _credential; + private string? _scope; - public ProviderManifest Manifest { get; } = new( - Id: "soprano", - Auth: new AuthConfig("oauth"), - ResponseMapping: new Dictionary + public SopranoProvider() + : this( + identity => new ManagedIdentityCredential(identity, OAuthOptions()), + (tenant, application, assertion) => + new ClientAssertionCredential(tenant, application, assertion, OAuthOptions())) + { + } + + internal SopranoProvider( + Func createIdentity, + Func>, TokenCredential> createCredential) + { + _createIdentity = createIdentity; + _createCredential = createCredential; + } + + public override string Name => "soprano"; + public override string AuthenticationMode => "oauth"; + + public override Task SendOtpAsync( + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials, + IEnv env, HttpClient client, int timeoutMs, ILogger? logger = null) => + SendJsonAsync( + () => CreateRequest(channel, endpoint, delivery, credentials, env), + MapResponse, + client, + timeoutMs, + logger); + + private static HttpRequestMessage CreateRequest( + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credential, IEnv env) + { + Voice? voice = null; + string? text = null; + if (channel == "voice") + { + var message = delivery.Message ?? string.Empty; + var passcode = Regex.Match(message, "[0-9]{6}"); + if (!passcode.Success) + throw new InvalidOperationException("voice message does not contain a six-digit passcode"); + voice = new Voice(new TextToVoiceRequest( + message[..passcode.Index], + passcode.Value, + message[(passcode.Index + passcode.Length)..], + string.IsNullOrWhiteSpace(delivery.Locale) ? DefaultVoiceLanguage : delivery.Locale, + VoiceGender, + VoiceLoop)); + } + else + { + text = delivery.Message; + } + + var body = new Request( + delivery.PhoneNumber.TrimStart('+'), + [channel == "voice" ? "voice" : "sms"], + delivery.CorrelationId ?? delivery.MessageId, + false, + voice, + text); + var request = new HttpRequestMessage(HttpMethod.Post, endpoint) { - ["ENROUTE"] = Outcome.Continue, - ["ACCEPTED"] = Outcome.Continue, - ["SUBMITTED"] = Outcome.Continue, - ["SENT"] = Outcome.Continue, - ["DELIVERED"] = Outcome.Continue, - ["QUEUED"] = Outcome.Continue, - ["FAILED"] = Outcome.Fail, - ["REJECTED"] = Outcome.Fail, - ["FILTERED"] = Outcome.Fail, - ["BLOCKED"] = Outcome.Block, - ["default"] = Outcome.Fail, - }); - - public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) + Content = JsonContent.Create(body), + }; + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Authorization = new("Bearer", credential.AccessToken); + return request; + } + + private static ProviderResult MapResponse(ResponseBody? responseBody, HttpStatusCode httpStatus) { - var headers = new Dictionary + var payload = responseBody?.Payload; + var status = payload?.Status is null + ? payload?.State?.Value + : payload.Status.Value; + status = string.IsNullOrWhiteSpace(status) ? "UNKNOWN" : status.ToUpperInvariant(); + var (outcome, recognized) = MapStatus(status); + var finalOutcome = (int)httpStatus is >= 200 and < 300 ? outcome : Outcome.Fail; + return new ProviderResult( + finalOutcome, + recognized, + (int)httpStatus, + payload?.Id?.Value ?? payload?.MessageId?.Value, + status) { - ["Content-Type"] = "application/json", - ["Accept"] = "application/json", - ["Authorization"] = "Bearer " + credential.AccessToken, + FailureReason = ClassifyFailure(httpStatus, finalOutcome, recognized), }; - var body = new Dictionary + } + + public override async Task FetchCredentialsAsync( + AppConfig config, CancellationToken cancellationToken = default) + { + ConfigureCredentials(config); + await GetAssertionAsync(cancellationToken).ConfigureAwait(false); + var token = CheckToken(await _credential!.GetTokenAsync( + new TokenRequestContext([_scope!]), + cancellationToken).ConfigureAwait(false)); + return new ProviderCredentials( + AuthenticationMode, + AccessToken: token.Token, + ExpiresOn: token.ExpiresOn - ExpirySkew); + } + + private void ConfigureCredentials(AppConfig config) + { + lock (_credentialGate) { - ["destination"] = dispatch.Destination.TrimStart('+'), - ["messageTypes"] = new[] { channel == "voice" ? "voice" : "sms" }, - ["correlationId"] = dispatch.CorrelationId ?? dispatch.MessageId, - ["shutterMode"] = false, + if (_credential is not null) return; + if (string.IsNullOrWhiteSpace(config.ProviderTenantId) + || string.IsNullOrWhiteSpace(config.ProviderScope) + || string.IsNullOrWhiteSpace(config.OutboundClientId) + || string.IsNullOrWhiteSpace(config.OutboundManagedIdentityClientId)) + throw CredentialTokenService.Unavailable(); + _scope = config.ProviderScope; + _identity = _createIdentity(config.OutboundManagedIdentityClientId); + _credential = _createCredential( + config.ProviderTenantId, + config.OutboundClientId, + async cancellation => (await GetAssertionAsync(cancellation).ConfigureAwait(false)).Token); + } + } + + private async Task GetAssertionAsync(CancellationToken cancellationToken) => + CheckToken(await _identity!.GetTokenAsync( + new TokenRequestContext(["api://AzureADTokenExchange/.default"]), + cancellationToken).ConfigureAwait(false)); + + private static AccessToken CheckToken(AccessToken token) + { + if (string.IsNullOrWhiteSpace(token.Token) + || token.ExpiresOn <= DateTimeOffset.UtcNow + ExpirySkew) + throw CredentialTokenService.Unavailable(); + return token; + } + + private static ClientAssertionCredentialOptions OAuthOptions() + { + var options = new ClientAssertionCredentialOptions + { + AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, + Retry = + { + MaxRetries = 0, + NetworkTimeout = CredentialTokenService.AcquisitionTimeout, + }, + Diagnostics = + { + IsLoggingEnabled = false, + IsLoggingContentEnabled = false, + }, }; - if (channel == "voice") + return options; + } + + private static (Outcome Outcome, bool Recognized) MapStatus(string? status) => status switch + { + "ENROUTE" or "ACCEPTED" or "SUBMITTED" or "SENT" or "DELIVERED" or "QUEUED" + => (Outcome.Continue, true), + "BLOCKED" => (Outcome.Block, true), + "FAILED" or "REJECTED" or "FILTERED" => (Outcome.Fail, true), + _ => (Outcome.Fail, false), + }; + + private sealed record Request( + [property: JsonPropertyName("destination")] string Destination, + [property: JsonPropertyName("messageTypes")] IReadOnlyList MessageTypes, + [property: JsonPropertyName("correlationId")] string CorrelationId, + [property: JsonPropertyName("shutterMode")] bool ShutterMode, + [property: JsonPropertyName("voice"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] Voice? Voice, + [property: JsonPropertyName("text"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Text); + + private sealed record Voice( + [property: JsonPropertyName("text2voice")] TextToVoiceRequest TextToVoice); + + private sealed record TextToVoiceRequest( + [property: JsonPropertyName("beforePasswordText")] string BeforePasswordText, + [property: JsonPropertyName("password")] string Password, + [property: JsonPropertyName("afterPasswordText")] string AfterPasswordText, + [property: JsonPropertyName("language")] string Language, + [property: JsonPropertyName("gender")] int Gender, + [property: JsonPropertyName("loop")] int Loop); + + [JsonConverter(typeof(ResponseBodyConverter))] + private sealed record ResponseBody(Response? Payload); + + private sealed record Response( + [property: JsonPropertyName("id")] StringOrNumber? Id, + [property: JsonPropertyName("messageId")] StringOrNumber? MessageId, + [property: JsonPropertyName("status")] ResponseString? Status, + [property: JsonPropertyName("state")] ResponseString? State); + + [JsonConverter(typeof(StringOrNumberConverter))] + private sealed record StringOrNumber(string? Value); + + [JsonConverter(typeof(ResponseStringConverter))] + private sealed record ResponseString(string? Value); + + private sealed class ResponseBodyConverter : JsonConverter + { + public override ResponseBody Read( + ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var message = dispatch.Message ?? string.Empty; - var passcode = Regex.Match(message, "[0-9]{6}"); - if (!passcode.Success) - throw new InvalidOperationException("voice message does not contain a six-digit passcode"); - body["voice"] = new + if (reader.TokenType == JsonTokenType.StartObject) + return new ResponseBody(JsonSerializer.Deserialize(ref reader, options)); + + if (reader.TokenType != JsonTokenType.StartArray) { - text2voice = new - { - beforePasswordText = message[..passcode.Index], - password = passcode.Value, - afterPasswordText = message[(passcode.Index + passcode.Length)..], - language = string.IsNullOrWhiteSpace(dispatch.Locale) ? DefaultVoiceLanguage : dispatch.Locale, - gender = VoiceGender, - loop = VoiceLoop, - }, - }; + reader.Skip(); + return new ResponseBody(null); + } + + if (!reader.Read() || reader.TokenType == JsonTokenType.EndArray) + return new ResponseBody(null); + + Response? payload = null; + if (reader.TokenType == JsonTokenType.StartObject) + payload = JsonSerializer.Deserialize(ref reader, options); + else + reader.Skip(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + reader.Skip(); + return new ResponseBody(payload); } - else + + public override void Write(Utf8JsonWriter writer, ResponseBody value, JsonSerializerOptions options) => + throw new NotSupportedException(); + } + + private sealed class StringOrNumberConverter : JsonConverter + { + public override StringOrNumber Read( + ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - body["text"] = dispatch.Message; + if (reader.TokenType == JsonTokenType.String) + return new StringOrNumber(reader.GetString()); + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt64(out var number)) + return new StringOrNumber(number.ToString(CultureInfo.InvariantCulture)); + reader.Skip(); + return new StringOrNumber(null); } - return new ProviderHttpRequest(endpoint, "POST", headers, JsonSerializer.Serialize(body)); + public override void Write(Utf8JsonWriter writer, StringOrNumber value, JsonSerializerOptions options) => + throw new NotSupportedException(); } - public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) + private sealed class ResponseStringConverter : JsonConverter { - var payload = json.ValueKind == JsonValueKind.Array && json.GetArrayLength() > 0 ? json[0] : json; - string? id = null, status = null; - if (payload.ValueKind == JsonValueKind.Object) + public override ResponseString Read( + ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (payload.TryGetProperty("id", out var idElement)) id = idElement.ToString(); - else if (payload.TryGetProperty("messageId", out var messageIdElement)) id = messageIdElement.ToString(); - if (!payload.TryGetProperty("status", out var statusElement) || statusElement.ValueKind == JsonValueKind.Null) - payload.TryGetProperty("state", out statusElement); - if (statusElement.ValueKind == JsonValueKind.String) status = statusElement.GetString(); + if (reader.TokenType == JsonTokenType.String) + return new ResponseString(reader.GetString()); + reader.Skip(); + return new ResponseString(null); } - status = string.IsNullOrWhiteSpace(status) ? "UNKNOWN" : status.ToUpperInvariant(); - return new ParsedResponse(ok, httpStatus, id, status); + + public override void Write(Utf8JsonWriter writer, ResponseString value, JsonSerializerOptions options) => + throw new NotSupportedException(); } } diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs index 3f1aa40..360883b 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -1,10 +1,15 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; namespace Epp.Otp.Providers; -public sealed class TelesignProvider : IProviderAdapter +public sealed class TelesignProvider : PhoneProviderBase { private const string VoiceDigitSeparator = ", "; private const int VoiceRepeatCount = 2; @@ -12,48 +17,46 @@ public sealed class TelesignProvider : IProviderAdapter private static readonly Regex VoicePasscodePattern = new( @"(? - { - ["200"] = Outcome.Continue, - ["203"] = Outcome.Continue, - ["290"] = Outcome.Continue, - ["291"] = Outcome.Continue, - ["292"] = Outcome.Continue, - ["100"] = Outcome.Continue, - ["101"] = Outcome.Continue, - ["102"] = Outcome.Continue, - ["103"] = Outcome.Continue, - ["3001"] = Outcome.Continue, - ["default"] = Outcome.Fail, - }); - - public ProviderHttpRequest BuildRequest(string channel, string endpoint, DispatchRequest dispatch, ProviderCredential credential, IEnv env) + public TelesignProvider(ISecretResolver? secrets = null) => _secrets = secrets; + + public override string Name => "telesign"; + public override string AuthenticationMode => "apiKey"; + + public override Task SendOtpAsync( + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credentials, + IEnv env, HttpClient client, int timeoutMs, ILogger? logger = null) => + SendJsonAsync( + () => CreateRequest(channel, endpoint, delivery, credentials, env), + MapResponse, + client, + timeoutMs, + logger); + + private static HttpRequestMessage CreateRequest( + string channel, string endpoint, OtpDelivery delivery, ProviderCredentials credential, IEnv env) { if (channel is not ("sms" or "voice")) throw new InvalidOperationException("unsupported channel"); - if (dispatch.Destination is null || !Regex.IsMatch(dispatch.Destination, @"\A\+[1-9][0-9]{1,14}\z")) + if (delivery.PhoneNumber is null || !Regex.IsMatch(delivery.PhoneNumber, @"\A\+[1-9][0-9]{1,14}\z")) throw new InvalidOperationException("invalid recipient"); - var authorization = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.Identity}:{credential.Secret}")); - var messageText = channel == "voice" ? BuildVoiceMessage(dispatch.Message!) : dispatch.Message; - var message = new Dictionary { ["text"] = messageText }; - if (!string.IsNullOrWhiteSpace(dispatch.Locale)) message["language"] = dispatch.Locale; - var body = new - { - recipient = new { phone_number = dispatch.Destination }, - message, - channels = new[] { new { channel } }, - correlation_id = string.IsNullOrEmpty(dispatch.CorrelationId) ? dispatch.MessageId : dispatch.CorrelationId, - }; - var headers = new Dictionary + + var authorization = "Basic " + Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{credential.Identity}:{credential.Secret}")); + var messageText = channel == "voice" ? BuildVoiceMessage(delivery.Message!) : delivery.Message; + var body = new Request( + new Recipient(delivery.PhoneNumber), + new Message(messageText, string.IsNullOrWhiteSpace(delivery.Locale) ? null : delivery.Locale), + [new Channel(channel)], + string.IsNullOrEmpty(delivery.CorrelationId) ? delivery.MessageId : delivery.CorrelationId); + + var request = new HttpRequestMessage(HttpMethod.Post, endpoint) { - ["Authorization"] = authorization, - ["Content-Type"] = "application/json", - ["Accept"] = "application/json", + Content = JsonContent.Create(body), }; - return new ProviderHttpRequest(endpoint, "POST", headers, JsonSerializer.Serialize(body)); + request.Headers.TryAddWithoutValidation("Authorization", authorization); + request.Headers.Accept.ParseAdd("application/json"); + return request; } private static string BuildVoiceMessage(string message) @@ -64,21 +67,68 @@ private static string BuildVoiceMessage(string message) return string.Join(VoiceRepeatSeparator, Enumerable.Repeat(pacedMessage, VoiceRepeatCount)); } - public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) + private static ProviderResult MapResponse(Response? payload, HttpStatusCode httpStatus) { - string? refId = null, statusCode = "UNKNOWN", statusDesc = null; - if (json.ValueKind == JsonValueKind.Object) + var statusCode = payload?.Status?.Code?.ToString(CultureInfo.InvariantCulture) ?? "UNKNOWN"; + var (outcome, recognized) = MapStatus(statusCode); + var finalOutcome = (int)httpStatus is >= 200 and < 300 ? outcome : Outcome.Fail; + return new ProviderResult( + finalOutcome, + recognized, + (int)httpStatus, + payload?.ReferenceId, + ProviderStatusCode: statusCode, + ProviderStatusDescription: payload?.Status?.Description) { - if (json.TryGetProperty("reference_id", out var referenceId) && referenceId.ValueKind == JsonValueKind.String) - refId = referenceId.GetString(); - if (json.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Object) - { - if (status.TryGetProperty("code", out var code) && code.ValueKind == JsonValueKind.Number && code.TryGetInt32(out var numericCode)) - statusCode = numericCode.ToString(System.Globalization.CultureInfo.InvariantCulture); - if (status.TryGetProperty("description", out var description) && description.ValueKind == JsonValueKind.String) - statusDesc = description.GetString(); - } - } - return new ParsedResponse(ok, httpStatus, refId, null, statusCode, statusDesc); + FailureReason = ClassifyFailure(httpStatus, finalOutcome, recognized), + }; } + + public override async Task FetchCredentialsAsync( + AppConfig config, CancellationToken cancellationToken = default) + { + if (_secrets is null) throw CredentialTokenService.Unavailable(); + var key = _secrets.ResolveAsync("telesign-api-key", cancellationToken); + var identity = _secrets.ResolveAsync("telesign-customer-id", cancellationToken); + await Task.WhenAll(key, identity).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(key.Result) + || string.IsNullOrWhiteSpace(identity.Result)) + throw CredentialTokenService.Unavailable(); + return new ProviderCredentials( + AuthenticationMode, + Secret: key.Result, + Identity: identity.Result, + ExpiresOn: DateTimeOffset.UtcNow.AddMinutes(5)); + } + + private static (Outcome Outcome, bool Recognized) MapStatus(string? status) => status switch + { + "200" or "203" or "290" or "291" or "292" + or "100" or "101" or "102" or "103" or "3001" => (Outcome.Continue, true), + _ => (Outcome.Fail, false), + }; + + private sealed record Request( + [property: JsonPropertyName("recipient")] Recipient Recipient, + [property: JsonPropertyName("message")] Message Message, + [property: JsonPropertyName("channels")] IReadOnlyList Channels, + [property: JsonPropertyName("correlation_id")] string CorrelationId); + + private sealed record Recipient( + [property: JsonPropertyName("phone_number")] string PhoneNumber); + + private sealed record Message( + [property: JsonPropertyName("text")] string? Text, + [property: JsonPropertyName("language"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Language); + + private sealed record Channel( + [property: JsonPropertyName("channel")] string Name); + + private sealed record Response( + [property: JsonPropertyName("reference_id")] string? ReferenceId, + [property: JsonPropertyName("status")] Status? Status); + + private sealed record Status( + [property: JsonPropertyName("code"), JsonNumberHandling(JsonNumberHandling.Strict)] int? Code = null, + [property: JsonPropertyName("description")] string? Description = null); } diff --git a/dotnet/Src/RequestLog.cs b/dotnet/Src/RequestLog.cs deleted file mode 100644 index 6687d70..0000000 --- a/dotnet/Src/RequestLog.cs +++ /dev/null @@ -1,266 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; -using System.Text.RegularExpressions; -using Microsoft.Extensions.Logging; - -namespace Epp.Otp; - -// Only explicitly selected metadata enters logs; never serialize delivery/provider models. -public sealed class RequestLog -{ - private static readonly string[] ContextFields = - { - "functionName", "functionRequestId", "functionInvocationId", - "x-ms-client-request-id", "x-ms-correlation-id", "msCorrelationIdSource", "omittedIdFields", - "channel", "evaluation", "providerName", - }; - private static readonly string[] CredentialFields = - { - "providerAuthMode", "providerCredentialSource", "providerTenantId", - "functionOutboundClientId", "functionOutboundManagedIdentityClientId", - }; - private static readonly Regex IdentifierPattern = new(@"\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z", RegexOptions.CultureInvariant); - - private readonly ILogger _logger; - private readonly Stopwatch _started = Stopwatch.StartNew(); - private readonly Dictionary _data; - private readonly List _omittedIdFields = new(); - private Stopwatch? _providerStarted; - private Stopwatch? _credentialStarted; - - public bool HasFailure => _data["failureStage"] is not null; - - public RequestLog(ILogger logger, string requestId, string? invocationId, string? msRequestId, string? msCorrelationId) - { - _logger = logger; - _data = new() - { - ["functionName"] = "SendOtp", - ["functionRequestId"] = requestId, - ["functionInvocationId"] = invocationId, - ["x-ms-client-request-id"] = null, - ["x-ms-correlation-id"] = null, - ["msCorrelationIdSource"] = "none", - ["omittedIdFields"] = Array.Empty(), - ["envelopeType"] = null, - ["ttlSeconds"] = null, - ["channel"] = null, - ["evaluation"] = null, - ["encryptionKeyIdMismatch"] = false, - ["providerName"] = null, - ["providerAuthMode"] = null, - ["providerCredentialSource"] = null, - ["providerCredentialElapsedMs"] = null, - ["providerTenantId"] = null, - ["functionOutboundClientId"] = null, - ["functionOutboundManagedIdentityClientId"] = null, - ["providerHttpMethod"] = null, - ["providerEndpoint"] = null, - ["providerAttempted"] = false, - ["providerHttpStatus"] = null, - ["providerStatus"] = null, - ["providerOutcome"] = null, - ["providerMessageId"] = null, - ["providerElapsedMs"] = null, - ["providerTimeoutMs"] = null, - ["failureStage"] = null, - ["failureReason"] = null, - ["responseContainsNonce"] = null, - ["responseContainsCorrelationId"] = null, - }; - SetIdentifier("x-ms-client-request-id", msRequestId); - SetIdentifier("x-ms-correlation-id", msCorrelationId); - _data["msCorrelationIdSource"] = _data["x-ms-correlation-id"] is null ? "none" : "header"; - } - - private void SetIdentifier(string field, string? value) - { - var valid = value is { Length: <= 128 } && IdentifierPattern.IsMatch(value); - _data[field] = valid ? value : null; - _omittedIdFields.Remove(field); - if (!valid && !string.IsNullOrWhiteSpace(value)) _omittedIdFields.Add(field); - _data["omittedIdFields"] = _omittedIdFields.ToArray(); - } - - public void Service(string eventName, Dictionary? details = null, LogLevel level = LogLevel.Information) - { - var record = new Dictionary { ["logType"] = "service", ["eventName"] = eventName }; - foreach (var field in ContextFields) record[field] = _data[field]; - if (details is not null) - foreach (var (key, value) in details) record[key] = value; - record["elapsedMs"] = _started.ElapsedMilliseconds; - Write(level, eventName, record); - } - - public void EnvelopeValidated(Envelope envelope, string? correlationId, string source) - { - _data["envelopeType"] = envelope.Type; - _data["ttlSeconds"] = envelope.TtlSeconds; - _data["channel"] = EnvelopeParser.ChannelName(envelope.Channel); - _data["evaluation"] = envelope.Mode == EnvelopeParser.ModeEvaluation; - SetIdentifier("x-ms-correlation-id", correlationId); - _data["msCorrelationIdSource"] = _data["x-ms-correlation-id"] is null ? "none" : source; - Service("envelope_validated", new() - { - ["envelopeType"] = _data["envelopeType"], - ["ttlSeconds"] = _data["ttlSeconds"], - ["encryptedDeliveryContextPresent"] = true, - }); - } - - public void KeyIdMismatch() - { - _data["encryptionKeyIdMismatch"] = true; - Service("encryption_key_id_mismatch", level: LogLevel.Warning); - } - - public void ProviderSelected(ProviderManifest manifest) - { - _data["providerName"] = manifest.Id; - _data["providerAuthMode"] = manifest.Auth.Mode is "apiKey" or "oauth" ? manifest.Auth.Mode : "unsupported"; - Service("provider_selected", new() { ["providerAuthMode"] = _data["providerAuthMode"] }); - } - - public void CredentialResolutionStarted(AppConfig config) - { - _credentialStarted = Stopwatch.StartNew(); - _data["providerCredentialSource"] = _data["providerAuthMode"] switch - { - "oauth" => "managed_identity_client_assertion", - "apiKey" => "key_vault", - _ => "unsupported", - }; - if (Equals(_data["providerAuthMode"], "oauth")) - { - SetIdentifier("providerTenantId", config.ProviderTenantId); - SetIdentifier("functionOutboundClientId", config.OutboundClientId); - SetIdentifier("functionOutboundManagedIdentityClientId", config.OutboundManagedIdentityClientId); - } - Service("provider_credential_resolution_started", CredentialDetails()); - } - - private Dictionary CredentialDetails() => - CredentialFields.ToDictionary(key => key, key => _data[key]); - - private void CredentialResolutionFinished() - { - if (_credentialStarted is null) return; - _data["providerCredentialElapsedMs"] = _credentialStarted.ElapsedMilliseconds; - _credentialStarted = null; - } - - public void CredentialResolved() - { - CredentialResolutionFinished(); - var details = CredentialDetails(); - details["providerCredentialElapsedMs"] = _data["providerCredentialElapsedMs"]; - Service("provider_credential_resolved", details); - } - - public void ProviderRequestBuilt(string? method, string endpoint) - { - var normalized = method?.ToUpperInvariant(); - _data["providerHttpMethod"] = normalized is "GET" or "HEAD" or "POST" or "PUT" or "DELETE" - or "CONNECT" or "OPTIONS" or "TRACE" or "PATCH" ? normalized : "other"; - var uri = new Uri(endpoint, UriKind.Absolute); - _data["providerEndpoint"] = uri.GetComponents(UriComponents.SchemeAndServer, UriFormat.UriEscaped) + uri.AbsolutePath; - Service("provider_request_built", new() - { - ["providerHttpMethod"] = _data["providerHttpMethod"], - ["providerEndpoint"] = _data["providerEndpoint"], - ["providerScheme"] = "https", - ["redirectsAllowed"] = false, - }); - } - - public void ProviderRequestStarted(int timeoutMs) - { - _providerStarted = Stopwatch.StartNew(); - _data["providerAttempted"] = true; - _data["providerTimeoutMs"] = timeoutMs; - Service("provider_request_started", new() - { - ["providerTimeoutMs"] = timeoutMs, - ["providerHttpMethod"] = _data["providerHttpMethod"], - ["providerEndpoint"] = _data["providerEndpoint"], - }); - } - - public void ProviderResponseReceived(int status) - { - _data["providerHttpStatus"] = status; - Service("provider_response_received", new() { ["providerHttpStatus"] = status }); - } - - public void ProviderRequestFinished() - { - if (_providerStarted is null) return; - _data["providerElapsedMs"] = _providerStarted.ElapsedMilliseconds; - _providerStarted = null; - } - - public void ProviderResponseProcessed(ProviderManifest manifest, ParsedResponse parsed, Outcome outcome, int httpStatus, bool validJson) - { - var status = parsed.ProviderStatusName ?? parsed.ProviderStatusCode; - var known = status is not null && status != "default" && manifest.ResponseMapping.ContainsKey(status); - _data["providerStatus"] = known ? status : "unmapped"; - _data["providerOutcome"] = outcome.ToString(); - SetIdentifier("providerMessageId", parsed.ProviderMessageId); - if (outcome != Outcome.Continue) - { - _data["failureStage"] = "provider_response"; - _data["failureReason"] = validJson ? "provider_rejected" : "invalid_provider_json"; - } - Service("provider_response_processed", new() - { - ["providerHttpStatus"] = _data["providerHttpStatus"], - ["providerStatus"] = _data["providerStatus"], - ["providerOutcome"] = _data["providerOutcome"], - ["providerMessageId"] = _data["providerMessageId"], - ["providerElapsedMs"] = _data["providerElapsedMs"], - ["httpStatus"] = httpStatus, - ["failureReason"] = _data["failureReason"], - }, httpStatus >= 500 ? LogLevel.Error : httpStatus == 200 ? LogLevel.Information : LogLevel.Warning); - } - - public void Failure(string stage, string reason, int httpStatus) - { - CredentialResolutionFinished(); - ProviderRequestFinished(); - _data["failureStage"] = stage; - _data["failureReason"] = reason; - Service(stage + "_failed", new() { ["failureReason"] = reason, ["httpStatus"] = httpStatus }, - httpStatus >= 500 ? LogLevel.Error : LogLevel.Warning); - } - - public void ResponsePrepared(int httpStatus, bool containsNonce, bool containsCorrelationId) - { - _data["responseContainsNonce"] = containsNonce; - _data["responseContainsCorrelationId"] = containsCorrelationId; - Service("response_prepared", new() - { - ["httpStatus"] = httpStatus, - ["responseContainsNonce"] = containsNonce, - ["responseContainsCorrelationId"] = containsCorrelationId, - }); - } - - public void Complete(int httpStatus) - { - CredentialResolutionFinished(); - ProviderRequestFinished(); - var record = new Dictionary(_data) - { - ["logType"] = "request", - ["eventName"] = "request_completed", - ["httpStatus"] = httpStatus, - ["result"] = httpStatus == 200 ? (Equals(_data["evaluation"], true) ? "evaluated" : "accepted") : "failed", - ["elapsedMs"] = _started.ElapsedMilliseconds, - }; - Write(LogLevel.Information, "request_completed", record); - } - - private void Write(LogLevel level, string eventName, Dictionary record) => - _logger.Log(level, new EventId(0, eventName), record, null, - static (state, _) => JsonSerializer.Serialize(state)); -} diff --git a/dotnet/Src/SecretResolver.cs b/dotnet/Src/SecretResolver.cs index 1b51652..1a365bf 100644 --- a/dotnet/Src/SecretResolver.cs +++ b/dotnet/Src/SecretResolver.cs @@ -4,7 +4,7 @@ namespace Epp.Otp; // Resolves Key Vault secret names to values via the Function's managed identity (user-assigned when -// AZURE_CLIENT_ID is set, else system-assigned). ApiKeyCache publishes the complete bundle. +// AZURE_CLIENT_ID is set, else system-assigned). public sealed class SecretResolver : ISecretResolver { private readonly object _gate = new(); @@ -24,17 +24,33 @@ private SecretClient GetClient() var url = _env.Get("KEY_VAULT_URL"); var clientId = _env.Get("AZURE_CLIENT_ID"); if (string.IsNullOrWhiteSpace(url)) throw new InvalidOperationException("KEY_VAULT_URL not set"); - var identityOptions = new TokenCredentialOptions(); - identityOptions.Retry.MaxRetries = 0; - identityOptions.Retry.NetworkTimeout = ProviderCredentials.AcquisitionTimeout; - identityOptions.Diagnostics.IsLoggingEnabled = false; - identityOptions.Diagnostics.IsLoggingContentEnabled = false; + var identityOptions = new TokenCredentialOptions + { + Retry = + { + MaxRetries = 0, + NetworkTimeout = CredentialTokenService.AcquisitionTimeout, + }, + Diagnostics = + { + IsLoggingEnabled = false, + IsLoggingContentEnabled = false, + }, + }; var credential = new ManagedIdentityCredential(clientId, identityOptions); - var options = new SecretClientOptions(); - options.Retry.MaxRetries = 0; - options.Retry.NetworkTimeout = ProviderCredentials.AcquisitionTimeout; - options.Diagnostics.IsLoggingEnabled = false; - options.Diagnostics.IsLoggingContentEnabled = false; + var options = new SecretClientOptions + { + Retry = + { + MaxRetries = 0, + NetworkTimeout = CredentialTokenService.AcquisitionTimeout, + }, + Diagnostics = + { + IsLoggingEnabled = false, + IsLoggingContentEnabled = false, + }, + }; _client = new SecretClient(new Uri(url), credential, options); return _client; } diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index 7746079..c0305f7 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; using System.Text.Json; using Epp.Otp.Providers; @@ -7,27 +8,26 @@ namespace Epp.Otp.Tests; public class ContractTests { - private static DispatchRequest Request(string channel = "sms") => + private static OtpDelivery Delivery(string channel = "sms") => new("+15551234567", " Your code is 918273.\nDo not share. ", channel, "message-id", "correlation-id", "en-US"); [Theory] [InlineData("sms")] [InlineData("voice")] - public void SopranoUsesSelectedEndpointAndOAuth(string channel) + public async Task SopranoUsesSelectedEndpointOAuthAndExactJson(string channel) { - var dispatch = Request(channel) with + var delivery = Delivery(channel) with { Locale = channel == "voice" ? "fr-FR" : "en-US", - TextToVoice = new TextToVoice("ignored", "001234", "override"), }; - var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/oauth/messages", dispatch, - new ProviderCredential("oauth", AccessToken: "provider-token"), new TestEnv()); - Assert.Equal("https://provider.example/oauth/messages", request.Url); - Assert.Equal("POST", request.Method); - Assert.Equal(3, request.Headers.Count); - Assert.Equal("Bear" + "er provider-token", request.Headers["Authorization"]); - Assert.Equal("application/json", request.Headers["Accept"]); - Assert.Equal("application/json", request.Headers["Content-Type"]); + var request = await new SopranoProvider().CaptureRequestAsync( + channel, + "https://provider.example/oauth/messages", + delivery, + new ProviderCredentials("oauth", AccessToken: "provider-token"), + new TestEnv()); + + AssertJsonRequest(request, "https://provider.example/oauth/messages", "Bearer provider-token"); var expected = new Dictionary { ["destination"] = "15551234567", @@ -49,7 +49,7 @@ public void SopranoUsesSelectedEndpointAndOAuth(string channel) }, }; else - expected["text"] = Request().Message; + expected["text"] = Delivery().Message; Assert.Equal(JsonSerializer.Serialize(expected), request.Body); } @@ -57,10 +57,13 @@ public void SopranoUsesSelectedEndpointAndOAuth(string channel) [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void SopranoVoiceDefaultsLanguageWithoutLocale(string? locale) + public async Task SopranoVoiceDefaultsLanguageWithoutLocale(string? locale) { - var request = new SopranoProvider().BuildRequest("voice", "https://provider.example/oauth/messages", - Request("voice") with { Locale = locale }, new ProviderCredential("oauth", AccessToken: "provider-token"), + var request = await new SopranoProvider().CaptureRequestAsync( + "voice", + "https://provider.example/oauth/messages", + Delivery("voice") with { Locale = locale }, + new ProviderCredentials("oauth", AccessToken: "provider-token"), new TestEnv()); using var body = JsonDocument.Parse(request.Body); Assert.Equal("en-US", body.RootElement.GetProperty("voice").GetProperty("text2voice") @@ -68,52 +71,181 @@ public void SopranoVoiceDefaultsLanguageWithoutLocale(string? locale) } [Fact] - public void SopranoVoiceRequiresSixDigitPasscode() + public async Task SopranoVoiceRequiresSixDigitPasscode() { - var dispatch = Request("voice") with { Message = "Your code is unavailable." }; - var error = Assert.Throws(() => new SopranoProvider().BuildRequest( - "voice", "https://provider.example/oauth/messages", dispatch, - new ProviderCredential("oauth", AccessToken: "provider-token"), new TestEnv())); - Assert.Contains("six-digit passcode", error.Message); + var delivery = Delivery("voice") with { Message = "Your code is unavailable." }; + var error = await Assert.ThrowsAsync( + () => new SopranoProvider().CaptureRequestAsync( + "voice", + "https://provider.example/oauth/messages", + delivery, + new ProviderCredentials("oauth", AccessToken: "provider-token"), + new TestEnv())); + Assert.Equal(502, error.StatusCode); + } + + [Theory] + [InlineData("{\"id\":12,\"state\":\"enroute\"}", "12", "ENROUTE", Outcome.Continue, true, null)] + [InlineData("[{\"messageId\":\"provider-id\",\"status\":\"accepted\"}]", "provider-id", "ACCEPTED", Outcome.Continue, true, null)] + [InlineData("{\"status\":\"FILTERED\"}", null, "FILTERED", Outcome.Fail, true, "provider_rejected")] + [InlineData("{\"status\":\"unknown\"}", null, "UNKNOWN", Outcome.Fail, false, "unrecognized_provider_status")] + [InlineData("{\"status\":123,\"state\":\"ACCEPTED\"}", null, "UNKNOWN", Outcome.Fail, false, "unrecognized_provider_status")] + [InlineData("{\"status\":false,\"state\":\"ACCEPTED\"}", null, "UNKNOWN", Outcome.Fail, false, "unrecognized_provider_status")] + [InlineData("[]", null, "UNKNOWN", Outcome.Fail, false, "unrecognized_provider_status")] + public async Task SopranoTypedResponseSupportsObjectAndArrayAndFailsClosed( + string body, + string? messageId, + string status, + Outcome expected, + bool recognized, + string? failureReason) + { + var provider = new SopranoProvider(); + using var response = JsonResponse(200, body); + var result = await provider.SendResponseAsync(response, default); + Assert.Equal(nameof(ProviderResult), result.ToString()); + Assert.Equal(messageId, result.ProviderMessageId); + Assert.Equal(status, result.ProviderStatusName); + Assert.Equal(expected, result.Outcome); + Assert.Equal(recognized, result.StatusRecognized); + Assert.Equal(failureReason, result.FailureReason); + } + + [Theory] + [InlineData("ENROUTE", Outcome.Continue, null)] + [InlineData("ACCEPTED", Outcome.Continue, null)] + [InlineData("SUBMITTED", Outcome.Continue, null)] + [InlineData("SENT", Outcome.Continue, null)] + [InlineData("DELIVERED", Outcome.Continue, null)] + [InlineData("QUEUED", Outcome.Continue, null)] + [InlineData("BLOCKED", Outcome.Block, null)] + [InlineData("FAILED", Outcome.Fail, "provider_rejected")] + [InlineData("REJECTED", Outcome.Fail, "provider_rejected")] + [InlineData("FILTERED", Outcome.Fail, "provider_rejected")] + public async Task SopranoOwnsEveryRecognizedStatusMapping( + string status, + Outcome expected, + string? failureReason) + { + using var response = JsonResponse(200, JsonSerializer.Serialize(new { status })); + var result = await new SopranoProvider().SendResponseAsync(response, default); + Assert.Equal(expected, result.Outcome); + Assert.True(result.StatusRecognized); + Assert.Equal(failureReason, result.FailureReason); } [Fact] - public void ProviderStatusesMapToExpectedOutcomesAndHttpCodes() + public void ProviderResultKeepsExistingConstructorAndDeconstructionShape() { - var adapter = new SopranoProvider(); - Outcome Parse(string body) + var result = new ProviderResult( + Outcome.Fail, + true, + 400, + "message-id", + "REJECTED", + "400", + "description") { - using var json = JsonDocument.Parse(body); - var response = adapter.ParseResponse(200, true, json.RootElement); - Assert.Equal(nameof(ParsedResponse), response.ToString()); - return OutcomeMapper.ResolveOutcome(adapter.Manifest, response); - } - Assert.Equal(Outcome.Continue, Parse("[{\"id\":12,\"state\":\"enroute\"}]")); - Assert.Equal(Outcome.Fail, Parse("{\"status\":\"FILTERED\"}")); - Assert.Equal(Outcome.Fail, Parse("{\"status\":\"unknown\"}")); - Assert.Equal(Outcome.Fail, Parse("{\"status\":123,\"state\":\"ACCEPTED\"}")); - Assert.Equal(Outcome.Fail, Parse("{\"status\":false,\"state\":\"ACCEPTED\"}")); - Assert.Equal(403, OutcomeMapper.ToHttpStatus(Outcome.Block, 200)); - Assert.Equal(409, OutcomeMapper.ToHttpStatus(Outcome.StepUp, 200)); - Assert.Equal(429, OutcomeMapper.ToHttpStatus(Outcome.Fail, 429)); + FailureReason = "provider_rejected", + }; + + var (outcome, recognized, httpStatus, messageId, statusName, statusCode, description) = result; + Assert.Equal(Outcome.Fail, outcome); + Assert.True(recognized); + Assert.Equal(400, httpStatus); + Assert.Equal("message-id", messageId); + Assert.Equal("REJECTED", statusName); + Assert.Equal("400", statusCode); + Assert.Equal("description", description); + Assert.Equal("provider_rejected", result.FailureReason); } [Fact] - public void OtherProvidersKeepTheirStaticAuthenticationAndProtocols() + public async Task InfobipRequestsKeepExactSmsAndVoiceWireContracts() { var env = new TestEnv { ["EPP_PROVIDER_ACCOUNT_NAME"] = "Verify" }; - var credential = new ProviderCredential("apiKey", "test-key", "test-id"); - var sms = new InfobipProvider().BuildRequest("sms", "https://provider.example", Request(), credential, env); - Assert.Equal("App test-key", sms.Headers["Authorization"]); - Assert.EndsWith("/sms/3/messages", sms.Url); - using var smsJson = JsonDocument.Parse(sms.Body); - Assert.Equal(Request().Message, smsJson.RootElement.GetProperty("messages")[0].GetProperty("content").GetProperty("text").GetString()); + var credential = new ProviderCredentials("apiKey", "test-key", "test-id"); - var call = new SinchProvider().BuildRequest("voice", "https://provider.example", Request("voice"), credential, env); - Assert.Equal("Bearer test-key", call.Headers["Authorization"]); // Static provider credential. - Assert.Equal("https://calling.api.sinch.com/calling/v1/callouts", call.Url); - using var callJson = JsonDocument.Parse(call.Body); - Assert.Equal(Request().Message, callJson.RootElement.GetProperty("ttsCallout").GetProperty("text").GetString()); + var sms = await new InfobipProvider().CaptureRequestAsync( + "sms", "https://provider.example", Delivery(), credential, env); + AssertJsonRequest(sms, "https://provider.example/sms/3/messages", "App test-key"); + var expectedSms = new + { + messages = new[] + { + new + { + sender = "Verify", + destinations = new[] { new { to = Delivery().PhoneNumber, messageId = "correlation-id" } }, + content = new { text = Delivery().Message }, + }, + }, + }; + Assert.Equal(JsonSerializer.Serialize(expectedSms), sms.Body); + + var voice = await new InfobipProvider().CaptureRequestAsync( + "voice", "https://provider.example", Delivery("voice"), credential, env); + AssertJsonRequest(voice, "https://provider.example/tts/3/advanced", "App test-key"); + var expectedVoice = new + { + messages = new[] + { + new + { + from = "Verify", + destinations = new[] { new { to = Delivery().PhoneNumber, messageId = "correlation-id" } }, + text = Delivery().Message, + language = "en-US", + voice = new { name = "Joanna", gender = "female" }, + }, + }, + }; + Assert.Equal(JsonSerializer.Serialize(expectedVoice), voice.Body); + } + + [Fact] + public async Task SinchRequestsKeepExactSmsAndVoiceWireContracts() + { + var env = new TestEnv + { + ["EPP_PROVIDER_ACCOUNT_NAME"] = "Verify", + ["SINCH_SERVICE_PLAN_ID"] = "service-plan", + ["SINCH_VOICE_ENDPOINT"] = "https://calling.example", + }; + var credential = new ProviderCredentials("apiKey", "test-key", "test-id"); + + var sms = await new SinchProvider().CaptureRequestAsync( + "sms", "https://provider.example", Delivery(), credential, env); + AssertJsonRequest( + sms, + "https://provider.example/xms/v1/service-plan/batches", + "******"); + Assert.Equal( + JsonSerializer.Serialize(new + { + from = "Verify", + to = new[] { Delivery().PhoneNumber }, + body = Delivery().Message, + client_reference = "correlation-id", + }), + sms.Body); + + var voice = await new SinchProvider().CaptureRequestAsync( + "voice", "https://provider.example", Delivery("voice"), credential, env); + AssertJsonRequest(voice, "https://calling.example/calling/v1/callouts", "******"); + Assert.Equal( + JsonSerializer.Serialize(new + { + method = "ttsCallout", + ttsCallout = new + { + destination = new { type = "number", endpoint = Delivery().PhoneNumber }, + text = Delivery().Message, + locale = "en-US", + custom = "correlation-id", + }, + }), + voice.Body); } [Theory] @@ -122,37 +254,44 @@ public void OtherProvidersKeepTheirStaticAuthenticationAndProtocols() [InlineData("sms", null)] [InlineData("sms", "")] [InlineData("sms", " ")] - public void TelesignUsesEppJsonContract(string channel, string? locale) - { - var dispatch = Request(channel) with { Locale = locale }; - var request = new TelesignProvider().BuildRequest(channel, $"https://verify.telesign.com/epp/{channel}", dispatch, - new ProviderCredential("apiKey", "test-key", "test-id"), new TestEnv()); - Assert.Equal($"https://verify.telesign.com/epp/{channel}", request.Url); - Assert.Equal("POST", request.Method); - Assert.Equal(3, request.Headers.Count); - Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test-id:test-key")), request.Headers["Authorization"]); - Assert.Equal("application/json", request.Headers["Content-Type"]); - Assert.Equal("application/json", request.Headers["Accept"]); + public async Task TelesignUsesExactEppJsonContract(string channel, string? locale) + { + var delivery = Delivery(channel) with { Locale = locale }; + var request = await new TelesignProvider().CaptureRequestAsync( + channel, + $"https://verify.telesign.com/epp/{channel}", + delivery, + new ProviderCredentials("apiKey", "test-key", "test-id"), + new TestEnv()); + AssertJsonRequest( + request, + $"https://verify.telesign.com/epp/{channel}", + "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test-id:test-key"))); var expectedText = channel == "voice" ? " Your code is 9, 1, 8, 2, 7, 3.\nDo not share. " + " Your code is 9, 1, 8, 2, 7, 3.\nDo not share. " - : dispatch.Message; + : delivery.Message; var message = new Dictionary { ["text"] = expectedText }; if (locale == "en") message["language"] = locale; - var expected = new { recipient = new { phone_number = dispatch.Destination }, message, - channels = new[] { new { channel } }, correlation_id = dispatch.CorrelationId }; + var expected = new + { + recipient = new { phone_number = delivery.PhoneNumber }, + message, + channels = new[] { new { channel } }, + correlation_id = delivery.CorrelationId, + }; Assert.Equal(JsonSerializer.Serialize(expected), request.Body); } [Fact] - public void TelesignVoicePacesOnlySixDigitNumericRunsAndRepeatsMessage() + public async Task TelesignVoicePacesOnlySixDigitNumericRunsAndRepeatsMessage() { - var dispatch = Request("voice") with { Message = "Code 001234; ref 1234567; alternate 654321." }; - var request = new TelesignProvider().BuildRequest( + var delivery = Delivery("voice") with { Message = "Code 001234; ref 1234567; alternate 654321." }; + var request = await new TelesignProvider().CaptureRequestAsync( "voice", "https://verify.telesign.com/epp/voice", - dispatch, - new ProviderCredential("apiKey", "test-key", "test-id"), + delivery, + new ProviderCredentials("apiKey", "test-key", "test-id"), new TestEnv()); using var body = JsonDocument.Parse(request.Body); Assert.Equal( @@ -162,41 +301,361 @@ public void TelesignVoicePacesOnlySixDigitNumericRunsAndRepeatsMessage() } [Fact] - public void TelesignValidatesRecipientAndFallsBackToMessageId() - { - var adapter = new TelesignProvider(); - var credential = new ProviderCredential("apiKey", "key", "id"); - foreach (var destination in new[] { "15551234567", "+0123", "+1", "+1234567890123456", "+123\n", "+123\r", "+12 34" }) - Assert.Throws(() => adapter.BuildRequest("sms", "https://verify.telesign.com", - Request() with { Destination = destination }, credential, new TestEnv())); - Assert.Throws(() => adapter.BuildRequest("email", "https://verify.telesign.com", Request(), credential, new TestEnv())); - var request = adapter.BuildRequest("sms", "https://verify.telesign.com", Request() with { CorrelationId = null }, credential, new TestEnv()); + public async Task TelesignValidatesRecipientAndFallsBackToMessageId() + { + var provider = new TelesignProvider(); + var credential = new ProviderCredentials("apiKey", "key", "id"); + foreach (var phoneNumber in new[] { "15551234567", "+0123", "+1", "+1234567890123456", "+123\n", "+123\r", "+12 34" }) + { + var error = await Assert.ThrowsAsync( + () => provider.CaptureRequestAsync( + "sms", + "https://verify.telesign.com", + Delivery() with { PhoneNumber = phoneNumber }, + credential, + new TestEnv())); + Assert.Equal(502, error.StatusCode); + } + var channelError = await Assert.ThrowsAsync( + () => provider.CaptureRequestAsync( + "email", "https://verify.telesign.com", Delivery(), credential, new TestEnv())); + Assert.Equal(502, channelError.StatusCode); + var request = await provider.CaptureRequestAsync( + "sms", + "https://verify.telesign.com", + Delivery() with { CorrelationId = null }, + credential, + new TestEnv()); using var json = JsonDocument.Parse(request.Body); - Assert.Equal(Request().MessageId, json.RootElement.GetProperty("correlation_id").GetString()); + Assert.Equal(Delivery().MessageId, json.RootElement.GetProperty("correlation_id").GetString()); + } + + [Theory] + [InlineData("{}", true, Outcome.Fail, false)] + [InlineData("{\"status\":{\"code\":999}}", true, Outcome.Fail, false)] + [InlineData("{\"status\":{\"code\":290}}", false, Outcome.Fail, true)] + [InlineData("{\"status\":{\"code\":290}}", true, Outcome.Continue, true)] + [InlineData("{\"status\":{\"code\":100}}", true, Outcome.Continue, true)] + [InlineData("{\"status\":{\"code\":3001}}", true, Outcome.Continue, true)] + [InlineData("{\"status\":{\"code\":3001}}", false, Outcome.Fail, true)] + public async Task TelesignMapsTypedStatus( + string payload, bool ok, Outcome expected, bool recognized) + { + var provider = new TelesignProvider(); + using var response = JsonResponse(ok ? 200 : 500, payload); + var result = await provider.SendResponseAsync(response, default); + Assert.Equal(expected, result.Outcome); + Assert.Equal(recognized, result.StatusRecognized); + } + + [Theory] + [InlineData("{\"status\":[]}")] + [InlineData("{\"status\":{\"code\":true}}")] + [InlineData("{\"status\":{\"code\":\"290\"}}")] + public async Task TelesignRejectsWronglyTypedStatus(string payload) + { + using var response = JsonResponse(200, payload); + var error = await Assert.ThrowsAsync( + async () => await new TelesignProvider().SendResponseAsync(response, default)); + Assert.Equal(502, error.StatusCode); + } + + [Theory] + [InlineData(200)] + [InlineData(203)] + [InlineData(290)] + [InlineData(291)] + [InlineData(292)] + [InlineData(100)] + [InlineData(101)] + [InlineData(102)] + [InlineData(103)] + [InlineData(3001)] + public async Task TelesignOwnsEveryContinueStatusMapping(int status) + { + using var response = JsonResponse(200, JsonSerializer.Serialize(new { status = new { code = status } })); + var result = await new TelesignProvider().SendResponseAsync(response, default); + Assert.Equal(Outcome.Continue, result.Outcome); + Assert.True(result.StatusRecognized); + } + + [Theory] + [InlineData("ACCEPTED", Outcome.Continue)] + [InlineData("PENDING", Outcome.Continue)] + [InlineData("DELIVERED", Outcome.Continue)] + [InlineData("REJECTED", Outcome.Fail)] + [InlineData("EXPIRED", Outcome.Fail)] + [InlineData("UNDELIVERABLE", Outcome.Fail)] + public async Task InfobipOwnsEveryRecognizedStatusMapping(string status, Outcome expected) + { + using var response = JsonResponse(200, JsonSerializer.Serialize(new + { + messages = new[] { new { messageId = "provider-id", status = new { groupName = status } } }, + })); + var result = await new InfobipProvider().SendResponseAsync(response, default); + Assert.Equal(expected, result.Outcome); + Assert.True(result.StatusRecognized); + } + + [Theory] + [InlineData("Dispatched", Outcome.Continue)] + [InlineData("Delivered", Outcome.Continue)] + [InlineData("Queued", Outcome.Continue)] + [InlineData("Failed", Outcome.Fail)] + [InlineData("Rejected", Outcome.Fail)] + public async Task SinchOwnsEveryRecognizedStatusMapping(string status, Outcome expected) + { + using var response = JsonResponse(200, JsonSerializer.Serialize(new { id = "provider-id", status })); + var result = await new SinchProvider().SendResponseAsync(response, default); + Assert.Equal(expected, result.Outcome); + Assert.True(result.StatusRecognized); + } + + [Fact] + public async Task TypedProviderResponsesNormalizeIdsStatusesAndDescriptions() + { + var cases = new (PhoneProviderBase Provider, string Json, string Id, string Status, string Description)[] + { + ( + new InfobipProvider(), + "{\"messages\":[{\"messageId\":\"infobip-id\",\"status\":{\"name\":\"pending\",\"description\":\"queued\"}}]}", + "infobip-id", + "PENDING", + "queued"), + ( + new SinchProvider(), + "{\"callId\":\"sinch-id\",\"text\":\"queued\"}", + "sinch-id", + "Dispatched", + "queued"), + ( + new TelesignProvider(), + "{\"reference_id\":\"telesign-id\",\"status\":{\"code\":3001,\"description\":\"queued\"}}", + "telesign-id", + "3001", + "queued"), + }; + + foreach (var item in cases) + { + using var response = JsonResponse(200, item.Json); + var result = await item.Provider.SendResponseAsync(response, default); + Assert.Equal(item.Id, result.ProviderMessageId); + Assert.Equal(item.Status, result.ProviderStatusName ?? result.ProviderStatusCode); + Assert.Equal(item.Description, result.ProviderStatusDescription); + Assert.Equal(Outcome.Continue, result.Outcome); + Assert.True(result.StatusRecognized); + } } [Theory] - [InlineData("{}", true, Outcome.Fail)] - [InlineData("{\"status\":[]}", true, Outcome.Fail)] - [InlineData("{\"status\":{\"code\":true}}", true, Outcome.Fail)] - [InlineData("{\"status\":{\"code\":\"290\"}}", true, Outcome.Fail)] - [InlineData("{\"status\":{\"code\":999}}", true, Outcome.Fail)] - [InlineData("{\"status\":{\"code\":290}}", false, Outcome.Fail)] - [InlineData("{\"status\":{\"code\":290}}", true, Outcome.Continue)] - [InlineData("{\"status\":{\"code\":100}}", true, Outcome.Continue)] - [InlineData("{\"status\":{\"code\":3001}}", true, Outcome.Continue)] - [InlineData("{\"status\":{\"code\":3001}}", false, Outcome.Fail)] - public void TelesignStatusFailsClosed(string payload, bool ok, Outcome expected) - { - var adapter = new TelesignProvider(); - using var json = JsonDocument.Parse(payload); - var parsed = adapter.ParseResponse(ok ? 200 : 500, ok, json.RootElement); - Assert.Equal(expected, OutcomeMapper.ResolveOutcome(adapter.Manifest, parsed)); + [InlineData("{}")] + [InlineData("null")] + public async Task MissingProviderResponseDataFailsClosed(string payload) + { + PhoneProviderBase[] providers = + [ + new InfobipProvider(), + new SinchProvider(), + new TelesignProvider(), + new SopranoProvider(), + ]; + foreach (var provider in providers) + { + using var response = JsonResponse(200, payload); + var result = await provider.SendResponseAsync(response, default); + Assert.Equal(Outcome.Fail, result.Outcome); + Assert.False(result.StatusRecognized); + } + } + + [Fact] + public async Task MalformedAndValidButUnbindableProviderJsonReturnProviderFailures() + { + var provider = new InfobipProvider(); + using var malformed = JsonResponse(200, "{\"messages\":["); + var malformedResult = await provider.SendResponseAsync(malformed, default); + Assert.Equal(Outcome.Fail, malformedResult.Outcome); + Assert.False(malformedResult.StatusRecognized); + + using var wrongType = JsonResponse( + 200, + "{\"messages\":[{\"messageId\":123,\"status\":{\"groupName\":\"PENDING\"}}]}"); + var wrongTypeError = await Assert.ThrowsAsync( + async () => await provider.SendResponseAsync(wrongType, default)); + Assert.Equal(502, wrongTypeError.StatusCode); + } + + [Theory] + [InlineData("{\"messages\":[{\"messageId\":\"id\",\"status\":{\"groupName\":123}}]}")] + [InlineData("{\"messages\":[{\"messageId\":\"id\",\"status\":[]}]}")] + [InlineData("{\"id\":\"id\",\"status\":123}")] + public async Task WronglyTypedProviderStatusesAreParseFailures(string payload) + { + PhoneProviderBase provider = payload.Contains("\"messages\"", StringComparison.Ordinal) + ? new InfobipProvider() + : new SinchProvider(); + using var response = JsonResponse(200, payload); + var error = await Assert.ThrowsAsync( + async () => await provider.SendResponseAsync(response, default)); + Assert.Equal(502, error.StatusCode); + } + + [Fact] + public async Task HttpFailureCannotBecomeSuccessFromProviderBody() + { + var cases = new (PhoneProviderBase Provider, string Body)[] + { + (new InfobipProvider(), "{\"messages\":[{\"messageId\":\"id\",\"status\":{\"groupName\":\"PENDING\"}}]}"), + (new SopranoProvider(), "{\"messageId\":\"id\",\"status\":\"ACCEPTED\"}"), + (new TelesignProvider(), "{\"reference_id\":\"id\",\"status\":{\"code\":290}}"), + (new SinchProvider(), "{\"id\":\"id\",\"status\":\"Delivered\"}"), + }; + + foreach (var item in cases) + { + using var response = JsonResponse(503, item.Body); + var result = await item.Provider.SendResponseAsync(response, default); + Assert.Equal(Outcome.Fail, result.Outcome); + Assert.True(result.StatusRecognized); + Assert.Equal("provider_http_error", result.FailureReason); + } } + private static void AssertJsonRequest( + CapturedRequest request, string expectedUrl, string expectedAuthorization) + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(expectedUrl, request.Url); + Assert.Equal(expectedAuthorization, request.Authorization); + Assert.Equal("application/json", request.Accept); + Assert.Equal( + new[] { "Accept", "Authorization" }, + request.HeaderNames); + Assert.Equal("application/json", request.ContentType); + Assert.Equal( + new[] { "Content-Type" }, + request.ContentHeaderNames); + } + + private static HttpResponseMessage JsonResponse(int status, string body) => + new((HttpStatusCode)status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; } internal sealed class TestEnv : Dictionary, IEnv { public string? Get(string key) => TryGetValue(key, out var value) ? value : null; } + +internal static class PhoneProviderTestExtensions +{ + public static async Task CaptureRequestAsync( + this PhoneProviderBase provider, + string channel, + string endpoint, + OtpDelivery delivery, + ProviderCredentials credentials, + IEnv env) + { + var handler = new CapturingRequestHandler(); + using var client = new HttpClient(handler); + await provider.SendOtpAsync( + channel, + endpoint, + delivery, + credentials, + env, + client, + 1500); + return handler.Request ?? throw new InvalidOperationException("Provider did not send a request."); + } + + public static async ValueTask SendResponseAsync( + this PhoneProviderBase provider, + HttpResponseMessage response, + CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + using var client = new HttpClient(new ResponseHandler( + () => new HttpResponseMessage(response.StatusCode) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + })); + var env = new TestEnv + { + ["EPP_PROVIDER_ACCOUNT_NAME"] = "Verify", + ["SINCH_SERVICE_PLAN_ID"] = "service-plan", + }; + var endpoint = provider.Name switch + { + "infobip" => "https://provider.example", + "sinch" => "https://provider.example", + "telesign" => "https://verify.telesign.com/epp/sms", + _ => "https://provider.example/oauth/messages", + }; + var credentials = provider.AuthenticationMode == "oauth" + ? new ProviderCredentials("oauth", AccessToken: "provider-token") + : new ProviderCredentials("apiKey", "provider-secret", "provider-identity"); + return await provider.SendOtpAsync( + "sms", + endpoint, + new OtpDelivery( + "+15551234567", + "Your code is 918273.", + "sms", + "message-id", + "correlation-id", + "en-US"), + credentials, + env, + client, + 1500); + } + + private sealed class CapturingRequestHandler : HttpMessageHandler + { + public CapturedRequest? Request { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Request = new CapturedRequest( + request.Method, + request.RequestUri?.AbsoluteUri, + Assert.Single(request.Headers.GetValues("Authorization")), + Assert.Single(request.Headers.Accept).MediaType, + request.Headers.Select(header => header.Key).OrderBy(name => name).ToArray(), + request.Content?.Headers.ContentType?.MediaType, + request.Content?.Headers.Select(header => header.Key).OrderBy(name => name).ToArray() ?? [], + request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }; + } + } + + private sealed class ResponseHandler(Func createResponse) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(createResponse()); + } +} + +internal sealed record CapturedRequest( + HttpMethod Method, + string? Url, + string Authorization, + string? Accept, + IReadOnlyList HeaderNames, + string? ContentType, + IReadOnlyList ContentHeaderNames, + string Body); diff --git a/dotnet/tests/CredentialCacheTests.cs b/dotnet/tests/CredentialCacheTests.cs deleted file mode 100644 index f49c4ec..0000000 --- a/dotnet/tests/CredentialCacheTests.cs +++ /dev/null @@ -1,423 +0,0 @@ -using System.Text.Json; -using Azure.Core; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Epp.Otp.Tests; - -public class CredentialCacheTests -{ - [Fact] - public async Task ColdReadersShareOneFetchAndValidValuesRemainAvailableDuringRefresh() - { - var clock = new ManualClock(); - var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var calls = 0; - using var manager = new ProviderCredentials(new Secrets(async (_, _) => - { - Interlocked.Increment(ref calls); - await release.Task; - return "value"; - }), _ => throw new Exception(), (_, _, _) => throw new Exception(), clock: clock); - Task Get() => manager.ResolveAsync(new("apiKey", "key"), new AppConfig()); - var readers = Enumerable.Range(0, 20).Select(_ => Get()).ToArray(); - Assert.Equal(1, calls); - release.SetResult(); - Assert.All(await Task.WhenAll(readers), value => Assert.Equal("value", value.Secret)); - Assert.Equal(1, calls); - Assert.Equal(1, clock.TimerCount); - release = new(TaskCreationOptions.RunContinuationsAsynchronously); - clock.Advance(TimeSpan.FromMinutes(4)); - Assert.Equal(2, calls); - Assert.Equal("value", (await Get()).Secret); - release.SetResult(); - await Until(() => clock.TimerCount == 1); - Assert.Equal("value", (await Get()).Secret); - manager.Dispose(); - Assert.Equal(0, clock.TimerCount); - } - - [Fact] - public async Task RefreshFailuresDoNotExtendExpiryAndUseFixedRetryCadence() - { - var clock = new ManualClock(); - var fail = false; - var calls = 0; - var log = new CredentialLogger(); - using var manager = new ProviderCredentials(new Secrets((_, _) => - { - calls++; - if (fail) throw new InvalidOperationException("PRIVATE-ERROR"); - return Task.FromResult("first"); - }), _ => throw new Exception(), (_, _, _) => throw new Exception(), log, clock); - Task Get() => manager.ResolveAsync(new("apiKey", "key"), new AppConfig()); - Assert.Equal("first", (await Get()).Secret); - fail = true; - clock.Advance(TimeSpan.FromMinutes(4)); - Assert.Single(log.Entries); - for (var i = 0; i < 10; i++) Assert.Equal("first", (await Get()).Secret); - Assert.Equal(2, calls); - clock.Advance(TimeSpan.FromMinutes(1)); - Assert.Equal(2, log.Entries.Count); - var error = await Assert.ThrowsAsync(Get); - Assert.Equal("provider credential unavailable", error.Message); - foreach (var delay in new[] { 30, 30, 30, 30, 30 }) - { - var before = calls; - clock.Advance(TimeSpan.FromSeconds(delay - 0.01)); - Assert.Equal(before, calls); - clock.Advance(TimeSpan.FromSeconds(0.01)); - Assert.Equal(before + 1, calls); - } - fail = false; - clock.Advance(TimeSpan.FromSeconds(30)); - Assert.Equal("first", (await Get()).Secret); - Assert.Equal(9, calls); - } - - [Fact] - public async Task CancellingAWaiterDoesNotCancelTheSharedRefresh() - { - var clock = new ManualClock(); - var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - CancellationToken observed = default; - using var manager = new ProviderCredentials(new Secrets(async (_, cancellation) => - { - observed = cancellation; - await release.Task; - return "ready"; - }), _ => throw new Exception(), (_, _, _) => throw new Exception(), clock: clock); - using var waiter = new CancellationTokenSource(); - var first = manager.ResolveAsync(new("apiKey", "key"), new AppConfig(), waiter.Token); - var second = manager.ResolveAsync(new("apiKey", "key"), new AppConfig()); - waiter.Cancel(); - await Assert.ThrowsAnyAsync(() => first); - Assert.False(observed.IsCancellationRequested); - release.SetResult(); - Assert.Equal("ready", (await second).Secret); - } - - [Fact] - public async Task CacheOwnedDeadlineAndShutdownPreventLatePublication() - { - var clock = new ManualClock(); - var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var log = new CredentialLogger(); - CancellationToken observed = default; - using var manager = new ProviderCredentials(new Secrets(async (_, cancellation) => - { - observed = cancellation; - await release.Task; - return "late"; - }), _ => throw new Exception(), (_, _, _) => throw new Exception(), log, clock); - var pending = manager.ResolveAsync(new("apiKey", "key"), new AppConfig()); - clock.Advance(TimeSpan.FromSeconds(2.5)); - await Assert.ThrowsAsync(() => pending); - Assert.True(observed.IsCancellationRequested); - Assert.Single(log.Entries); - manager.Dispose(); - release.SetResult(); - await Assert.ThrowsAsync(() => manager.ResolveAsync(new("apiKey", "key"), new AppConfig())); - Assert.Equal(0, clock.TimerCount); - } - - [Fact] - public async Task ApiKeyPairIsFetchedInParallelAndPublishedAsOneBundle() - { - var clock = new ManualClock(); - var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var calls = new List(); - var version = 1; - var failIdentity = false; - var secrets = new Secrets(async (name, _) => - { - calls.Add(name!); - await release.Task; - if (failIdentity && name == "id") throw new InvalidOperationException("PRIVATE-ERROR"); - return name + "-" + version; - }); - using var manager = new ProviderCredentials(secrets, _ => throw new Exception(), - (_, _, _) => throw new Exception(), clock: clock); - var auth = new AuthConfig("apiKey", "key", "id"); - var pending = Enumerable.Range(0, 10).Select(_ => manager.ResolveAsync(auth, new AppConfig())).ToArray(); - Assert.Equal(new[] { "key", "id" }, calls); - release.SetResult(); - foreach (var value in await Task.WhenAll(pending)) - { - Assert.Equal("key-1", value.Secret); - Assert.Equal("id-1", value.Identity); - } - version = 2; - failIdentity = true; - clock.Advance(TimeSpan.FromMinutes(4)); - var old = await manager.ResolveAsync(auth, new AppConfig()); - Assert.Equal("key-1", old.Secret); - Assert.Equal("id-1", old.Identity); - failIdentity = false; - clock.Advance(TimeSpan.FromSeconds(30)); - var next = await manager.ResolveAsync(auth, new AppConfig()); - Assert.Equal("key-2", next.Secret); - Assert.Equal("id-2", next.Identity); - } - - [Fact] - public async Task SdkCredentialsAreReusedByOneAccessTokenCacheWithoutKeyVaultCalls() - { - var clock = new ManualClock(); - var identityCalls = 0; - var providerCalls = 0; - var credentialInstances = 0; - using var manager = new ProviderCredentials(new Secrets((_, _) => throw new Exception("Unexpected Key Vault")), - _ => new Token(async (_, _) => - { - Interlocked.Increment(ref identityCalls); - await Task.Yield(); - return new("PRIVATE-ASSERTION", clock.GetUtcNow().AddHours(1)); - }), (_, _, assertion) => - { - credentialInstances++; - return new Token(async (_, cancellation) => - { - Interlocked.Increment(ref providerCalls); - Assert.Equal("PRIVATE-ASSERTION", await assertion(cancellation)); - Assert.Equal("PRIVATE-ASSERTION", await assertion(cancellation)); - return new("PRIVATE-PROVIDER", clock.GetUtcNow().AddHours(1)); - }); - }, clock: clock); - var config = Config(); - var initial = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => manager.ResolveAsync(new("oauth"), config))); - Assert.All(initial, result => Assert.Equal("PRIVATE-PROVIDER", result.AccessToken)); - Assert.Equal(3, identityCalls); - Assert.Equal(1, providerCalls); - await manager.ResolveAsync(new("oauth"), config); - Assert.Equal(1, providerCalls); - clock.Advance(TimeSpan.FromMinutes(1)); - await Until(() => identityCalls == 6 && providerCalls == 2 && clock.TimerCount == 1); - Assert.Equal(1, credentialInstances); - await manager.ResolveAsync(new("oauth"), config); - Assert.Equal(6, identityCalls); - Assert.Equal(2, providerCalls); - Assert.Equal(1, credentialInstances); - manager.Dispose(); - Assert.Equal(0, clock.TimerCount); - } - - [Fact] - public async Task RepeatedSdkTokenDoesNotExtendLifetimeOrCauseATightRefreshLoop() - { - var clock = new ManualClock(); - var expiry = clock.GetUtcNow().AddHours(1); - var calls = 0; - using var manager = new ProviderCredentials(new Secrets((_, _) => throw new Exception()), - _ => new Token((_, _) => ValueTask.FromResult(new AccessToken("assertion", expiry))), - (_, _, _) => new Token((_, _) => - { - calls++; - return ValueTask.FromResult(new AccessToken("token", expiry)); - }), clock: clock); - await manager.ResolveAsync(new("oauth"), Config()); - clock.Advance(TimeSpan.FromMinutes(55)); - Assert.Equal(2, calls); - await manager.ResolveAsync(new("oauth"), Config()); - Assert.Equal(2, calls); - clock.Advance(TimeSpan.FromMinutes(1)); - Assert.Equal(3, calls); - clock.Advance(TimeSpan.FromSeconds(210)); - await Assert.ThrowsAsync(() => manager.ResolveAsync(new("oauth"), Config())); - } - - [Fact] - public async Task DisposingManagerIsTerminalAndCancelsPendingAcquisition() - { - var clock = new ManualClock(); - var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var calls = 0; - CancellationToken acquisition = default; - using var manager = new ProviderCredentials(new Secrets((_, cancellation) => - { - calls++; - acquisition = cancellation; - return release.Task; - }), _ => throw new Exception("Unexpected managed identity"), - (_, _, _) => throw new Exception("Unexpected OAuth"), clock: clock); - var auth = new AuthConfig("apiKey", "key"); - var pending = manager.ResolveAsync(auth, new AppConfig()); - manager.Dispose(); - manager.Dispose(); - Assert.True(acquisition.IsCancellationRequested); - await Assert.ThrowsAsync(() => pending); - release.SetResult("PRIVATE-LATE-KEY"); - await Assert.ThrowsAsync(() => manager.ResolveAsync(auth, new AppConfig())); - await Assert.ThrowsAsync(() => manager.ResolveAsync(new("oauth"), Config())); - Assert.Equal(1, calls); - Assert.Equal(0, clock.TimerCount); - } - - [Fact] - public async Task InvalidInitialConfigurationDoesNotCreateClientsOrStartATimer() - { - var clock = new ManualClock(); - var instances = 0; - using var manager = new ProviderCredentials(new Secrets((_, _) => throw new Exception("Unexpected Key Vault")), - _ => new Token((_, _) => - ValueTask.FromResult(new AccessToken("assertion", clock.GetUtcNow().AddHours(1)))), - (_, _, assertion) => - { - instances++; - return new Token(async (_, cancellation) => - { - await assertion(cancellation); - return new("provider-token", clock.GetUtcNow().AddHours(1)); - }); - }, clock: clock); - await Assert.ThrowsAsync(() => manager.ResolveAsync(new("oauth"), Config(scope: ""))); - Assert.Equal(0, clock.TimerCount); - Assert.Equal(0, instances); - Assert.Equal("provider-token", (await manager.ResolveAsync(new("oauth"), Config())).AccessToken); - Assert.Equal(1, instances); - Assert.Equal(1, clock.TimerCount); - } - - [Fact] - public async Task ApiKeyCacheReplacesOneCompleteEntryAndStops() - { - var clock = new ManualClock(); - var version = "first"; - using var cache = new ApiKeyCache(new Secrets((_, _) => Task.FromResult(version)), new("apiKey", "key"), clock); - await cache.RefreshAsync(default); - Assert.Equal("first", cache.Get()?.Secret); - version = "second"; - clock.Advance(TimeSpan.FromMinutes(4)); - await cache.RefreshAsync(default); - Assert.Equal("second", cache.Get()?.Secret); - cache.Dispose(); - Assert.Null(cache.Get()); - } - - [Fact] - public async Task RefreshFailureUsesStructuredSanitizedLogRecord() - { - var clock = new ManualClock(); - var logger = new CredentialLogger(); - using var manager = new ProviderCredentials(new Secrets((_, _) => - throw new InvalidOperationException("PRIVATE-SDK-ERROR")), - _ => throw new Exception("Unexpected managed identity"), - (_, _, _) => throw new Exception("Unexpected OAuth"), logger, clock); - await Assert.ThrowsAsync(() => - manager.ResolveAsync(new("apiKey", "key"), new AppConfig())); - var entry = Assert.Single(logger.Entries); - Assert.Equal(LogLevel.Warning, entry.Level); - Assert.Equal("credential_refresh_failed", entry.EventId.Name); - Assert.Null(entry.Error); - Assert.Equal(4, entry.State.Count); - Assert.Equal("service", entry.State["logType"]); - Assert.Equal("credential_refresh_failed", entry.State["eventName"]); - Assert.Equal("key_vault", entry.State["cacheKind"]); - Assert.Equal("credential_unavailable", entry.State["failureReason"]); - using var json = JsonDocument.Parse(entry.Message); - Assert.Equal("key_vault", json.RootElement.GetProperty("cacheKind").GetString()); - Assert.DoesNotContain("PRIVATE", entry.Message); - } - - private static AppConfig Config(string scope = "api://provider/.default", string application = "app") => new() - { - ProviderTenantId = "tenant", ProviderScope = scope, OutboundClientId = application, - OutboundManagedIdentityClientId = "identity", - }; - - private static async Task Until(Func condition) - { - for (var i = 0; i < 200; i++) - { - if (condition()) return; - await Task.Delay(5); - } - Assert.True(condition()); - } - - private sealed class Secrets(Func> resolve) : ISecretResolver - { - public Task ResolveAsync(string? name, CancellationToken cancellationToken = default) => resolve(name, cancellationToken); - } - - private sealed class Token(Func> acquire) : TokenCredential - { - public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) => - throw new InvalidOperationException("Synchronous acquisition was not expected"); - public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) => - acquire(requestContext, cancellationToken); - } - - private sealed record LogEntry(LogLevel Level, EventId EventId, IReadOnlyDictionary State, - Exception? Error, string Message); - - private sealed class CredentialLogger : ILogger - { - internal List Entries { get; } = new(); - public IDisposable? BeginScope(TState state) where TState : notnull => null; - public bool IsEnabled(LogLevel level) => true; - public void Log(LogLevel level, EventId eventId, TState state, Exception? error, - Func formatter) - { - var record = Assert.IsAssignableFrom>(state); - Entries.Add(new(level, eventId, record, error, formatter(state, error))); - } - } - - private sealed class ManualClock : TimeProvider - { - private readonly object _gate = new(); - private readonly List _timers = new(); - private DateTimeOffset _now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); - public override DateTimeOffset GetUtcNow() { lock (_gate) return _now; } - public int TimerCount { get { lock (_gate) return _timers.Count(timer => !timer.Disposed && timer.Due != DateTimeOffset.MaxValue); } } - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - { - lock (_gate) - { - var timer = new ManualTimer(this, callback, state); - timer.Change(dueTime, period); - _timers.Add(timer); - return timer; - } - } - public void Advance(TimeSpan duration) - { - ManualTimer[] due; - lock (_gate) - { - _now += duration; - due = _timers.Where(timer => !timer.Disposed && timer.Due <= _now).ToArray(); - } - foreach (var timer in due) timer.Fire(); - } - - private sealed class ManualTimer(ManualClock clock, TimerCallback callback, object? state) : ITimer - { - internal bool Disposed; - internal DateTimeOffset Due; - private TimeSpan _period; - public bool Change(TimeSpan dueTime, TimeSpan period) - { - lock (clock._gate) - { - if (Disposed) return false; - Due = dueTime == Timeout.InfiniteTimeSpan ? DateTimeOffset.MaxValue : clock._now + dueTime; - _period = period; - return true; - } - } - internal void Fire() - { - lock (clock._gate) - { - if (Disposed) return; - Due = _period == Timeout.InfiniteTimeSpan ? DateTimeOffset.MaxValue : clock._now + _period; - } - callback(state); - } - public void Dispose() { lock (clock._gate) { Disposed = true; clock._timers.Remove(this); } } - public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } - } - } -} diff --git a/dotnet/tests/CredentialTokenServiceTests.cs b/dotnet/tests/CredentialTokenServiceTests.cs new file mode 100644 index 0000000..1d1bd93 --- /dev/null +++ b/dotnet/tests/CredentialTokenServiceTests.cs @@ -0,0 +1,225 @@ +using System.Text.Json; +using Azure.Core; +using Epp.Otp.Providers; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Epp.Otp.Tests; + +public class CredentialTokenServiceTests +{ + [Fact] + public async Task CachedCredentialsAreReusedUntilExpiration() + { + var calls = 0; + using var service = CreateService(); + Task Fetch(CancellationToken cancellation) + { + Interlocked.Increment(ref calls); + return Task.FromResult(ApiKey("value")); + } + var provider = new TestProvider("provider", Fetch); + + Assert.Equal("value", (await service.GetCredentialsAsync(provider, new AppConfig())).Secret); + Assert.Equal("value", (await service.GetCredentialsAsync(provider, new AppConfig())).Secret); + Assert.Equal(1, calls); + } + + [Fact] + public async Task MemoryCacheExpiresCredentialsAndFetchesAReplacement() + { + var calls = 0; + using var service = CreateService(); + Task Fetch(CancellationToken cancellation) => + Task.FromResult(ApiKey("value-" + Interlocked.Increment(ref calls), TimeSpan.FromMilliseconds(50))); + var provider = new TestProvider("provider", Fetch); + + Assert.Equal("value-1", (await service.GetCredentialsAsync(provider, new AppConfig())).Secret); + Assert.Equal("value-1", (await service.GetCredentialsAsync(provider, new AppConfig())).Secret); + await Task.Delay(100); + Assert.Equal("value-2", (await service.GetCredentialsAsync(provider, new AppConfig())).Secret); + } + + [Fact] + public async Task CallerCancellationCancelsItsCredentialFetch() + { + CancellationToken observed = default; + using var service = CreateService(); + async Task Fetch(CancellationToken cancellation) + { + observed = cancellation; + await Task.Delay(Timeout.InfiniteTimeSpan, cancellation); + return ApiKey("ready"); + } + var provider = new TestProvider("provider", Fetch); + + using var waiter = new CancellationTokenSource(); + var pending = service.GetCredentialsAsync(provider, new AppConfig(), waiter.Token); + waiter.Cancel(); + await Assert.ThrowsAnyAsync(() => pending); + Assert.True(observed.IsCancellationRequested); + } + + [Fact] + public async Task DisposalPreventsFurtherUse() + { + using var service = CreateService(); + service.Dispose(); + var provider = new TestProvider("provider", _ => Task.FromResult(ApiKey("unused"))); + await Assert.ThrowsAsync(() => + service.GetCredentialsAsync(provider, new AppConfig())); + } + + [Fact] + public async Task OAuthUsesManagedIdentityAssertionAndCachesTheProviderToken() + { + var identityCalls = 0; + var providerCalls = 0; + var credentialInstances = 0; + var provider = new SopranoProvider( + _ => new Token(async (_, _) => + { + Interlocked.Increment(ref identityCalls); + await Task.Yield(); + return new("PRIVATE-ASSERTION", DateTimeOffset.UtcNow.AddHours(1)); + }), + (_, _, assertion) => + { + credentialInstances++; + return new Token(async (_, cancellation) => + { + Interlocked.Increment(ref providerCalls); + Assert.Equal("PRIVATE-ASSERTION", await assertion(cancellation)); + return new("PRIVATE-PROVIDER", DateTimeOffset.UtcNow.AddHours(1)); + }); + }); + using var service = CreateService(); + var config = OAuthConfig(); + + var initial = await service.GetCredentialsAsync(provider, config); + Assert.Equal("PRIVATE-PROVIDER", initial.AccessToken); + await service.GetCredentialsAsync(provider, config); + Assert.Equal(2, identityCalls); + Assert.Equal(1, providerCalls); + Assert.Equal(1, credentialInstances); + } + + [Fact] + public async Task InvalidOAuthConfigurationDoesNotCreateSdkCredentials() + { + var instances = 0; + var provider = new SopranoProvider( + _ => new Token((_, _) => + ValueTask.FromResult(new AccessToken("assertion", DateTimeOffset.UtcNow.AddHours(1)))), + (_, _, _) => + { + instances++; + return new Token((_, _) => + ValueTask.FromResult(new AccessToken("provider", DateTimeOffset.UtcNow.AddHours(1)))); + }); + using var service = CreateService(); + + await Assert.ThrowsAsync(() => + service.GetCredentialsAsync(provider, OAuthConfig(scope: ""))); + Assert.Equal(0, instances); + } + + [Fact] + public async Task FetchFailureUsesStructuredSanitizedLogRecord() + { + var logger = new CredentialLogger(); + using var service = new CredentialTokenService(log: logger); + var provider = new TestProvider( + "provider", + _ => throw new InvalidOperationException("PRIVATE-SDK-ERROR")); + + await Assert.ThrowsAsync(() => + service.GetCredentialsAsync(provider, new AppConfig())); + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Equal("credential_refresh_failed", entry.EventId.Name); + Assert.Null(entry.Error); + Assert.Equal("provider", entry.State["CacheKind"]); + Assert.Contains("credential unavailable", entry.Message); + Assert.DoesNotContain("PRIVATE", entry.Message); + } + + private static CredentialTokenService CreateService() => + new(); + + private static ProviderCredentials ApiKey(string value, TimeSpan? lifetime = null) => + new( + "apiKey", + Secret: value, + ExpiresOn: DateTimeOffset.UtcNow + (lifetime ?? TimeSpan.FromMinutes(5))); + + private static AppConfig OAuthConfig(string scope = "api://provider/.default") => new() + { + ProviderTenantId = "tenant", + ProviderScope = scope, + OutboundClientId = "application", + OutboundManagedIdentityClientId = "identity", + }; + + private sealed class TestProvider( + string name, + Func> fetch) : PhoneProviderBase + { + public override string Name => name; + public override string AuthenticationMode => "test"; + public override Task FetchCredentialsAsync( + AppConfig config, + CancellationToken cancellationToken = default) => + fetch(cancellationToken); + public override Task SendOtpAsync( + string channel, + string endpoint, + OtpDelivery delivery, + ProviderCredentials credentials, + IEnv env, + HttpClient client, + int timeoutMs, + ILogger? logger = null) => + throw new NotSupportedException(); + } + + private sealed class Token( + Func> acquire) : TokenCredential + { + public override AccessToken GetToken( + TokenRequestContext requestContext, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Synchronous acquisition was not expected"); + + public override ValueTask GetTokenAsync( + TokenRequestContext requestContext, + CancellationToken cancellationToken) => + acquire(requestContext, cancellationToken); + } + + private sealed record LogEntry( + LogLevel Level, + EventId EventId, + IReadOnlyDictionary State, + Exception? Error, + string Message); + + private sealed class CredentialLogger : ILogger + { + internal List Entries { get; } = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel level) => true; + public void Log( + LogLevel level, + EventId eventId, + TState state, + Exception? error, + Func formatter) + { + var record = Assert.IsAssignableFrom>>(state) + .Where(pair => pair.Key != "{OriginalFormat}") + .ToDictionary(pair => pair.Key, pair => pair.Value); + Entries.Add(new(level, eventId, record, error, formatter(state, error))); + } + } +} diff --git a/dotnet/tests/EnvelopeTests.cs b/dotnet/tests/EnvelopeTests.cs deleted file mode 100644 index 155965a..0000000 --- a/dotnet/tests/EnvelopeTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Text.Json; -using Xunit; - -namespace Epp.Otp.Tests; - -public class EnvelopeTests -{ - [Theory] - [InlineData(true, true)] - [InlineData(true, false)] - [InlineData(false, true)] - [InlineData(false, false)] - public void KeyVaultPemCertificateBundleDecrypts(bool certificateFirst, bool base64Encoded) - { - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=EPP-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1)); - var publicPem = certificate.ExportCertificatePem(); - var privatePem = rsa.ExportPkcs8PrivateKeyPem(); - var bundle = certificateFirst ? $"{publicPem}\n{privatePem}" : $"{privatePem}\n{publicPem}"; - var env = new TestEnv - { - ["EPP_DECRYPTION_KEY_PEM"] = base64Encoded ? Convert.ToBase64String(Encoding.UTF8.GetBytes(bundle)) : bundle - }; - var provider = new EnvJweKeyProvider(env); - using var imported = provider.GetPrivateKey("test-key"); - var compact = Jose.JWT.Encode("{\"nonce\":\"test-nonce\"}", rsa, - Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); - Assert.Equal("test-nonce", new JweDecryptor(provider).Decrypt(compact).Context.Nonce); - Assert.Same(imported, provider.GetPrivateKey("test-key")); - } - - [Theory] - [InlineData("\"channel\":1,\"mode\":2,\"ttlSeconds\":60", "sms", 2, 60)] - [InlineData("\"channel\":\"VOICE\",\"mode\":\"Live\"", "voice", 1, null)] - public async Task NumericAndNamedRoutingParse(string routing, string channel, int mode, int? ttl) - { - var (envelope, error) = Parse(routing); - Assert.Null(error); - Assert.NotNull(envelope); - Assert.Equal(channel, EnvelopeParser.ChannelName(envelope.Channel)); - Assert.Equal(mode, envelope.Mode); - Assert.Equal(ttl, envelope.TtlSeconds); - using var body = new MemoryStream(Encoding.UTF8.GetBytes(Payload(routing))); - Assert.Equal((envelope, error), await EnvelopeParser.ParseAsync(body)); - Assert.True(body.CanRead); - } - - [Fact] - public async Task StreamParserRejectsInvalidUtf8ButPropagatesCancellationAndReadErrors() - { - var bytes = Encoding.UTF8.GetBytes("{\"type\":\"private-input\"}"); - bytes[9] = 0xff; - using var invalidUtf8 = new MemoryStream(bytes); - var (envelope, error) = await EnvelopeParser.ParseAsync(invalidUtf8); - Assert.Null(envelope); - Assert.Equal("invalid JSON body", error); - - using var cancelled = new CancellationTokenSource(); - cancelled.Cancel(); - using var body = new MemoryStream(Encoding.UTF8.GetBytes("{}")); - await Assert.ThrowsAnyAsync(() => EnvelopeParser.ParseAsync(body, cancelled.Token)); - using var unreadable = new UnreadableBody(); - await Assert.ThrowsAsync(() => EnvelopeParser.ParseAsync(unreadable)); - } - - [Fact] - public void RealJweRejectsTagTamperingAndMissingSegments() - { - using var keys = new TestKeys(); - var decryptor = new JweDecryptor(keys); - var compact = Jose.JWT.Encode("{\"nonce\":\"private-nonce\"}", keys.Rsa, - Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); - var parts = compact.Split('.'); - parts[4] = (parts[4][0] == 'A' ? "B" : "A") + parts[4][1..]; - Assert.ThrowsAny(() => decryptor.Decrypt(string.Join(".", parts))); - Assert.ThrowsAny(() => decryptor.Decrypt(string.Join(".", parts.Take(4)))); - } - - [Fact] - public void JweAuthenticatesOriginalProtectedHeaderBytes() - { - using var keys = new TestKeys(); - const string header = "{ \"kid\" : \"test-key\", \"enc\" : \"A256GCM\", \"alg\" : \"RSA-OAEP-256\" }"; - static string Encode(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - var encodedHeader = Encode(Encoding.UTF8.GetBytes(header)); - var key = RandomNumberGenerator.GetBytes(32); - var iv = RandomNumberGenerator.GetBytes(12); - var plaintext = Encoding.UTF8.GetBytes("{\"nonce\":\"test-nonce\",\"phoneNumber\":\"+15551234567\",\"message\":\"message\"}"); - var ciphertext = new byte[plaintext.Length]; - var tag = new byte[16]; - using var cipher = new AesGcm(key, tag.Length); - cipher.Encrypt(iv, plaintext, ciphertext, tag, Encoding.ASCII.GetBytes(encodedHeader)); - var wrappedKey = keys.Rsa.Encrypt(key, RSAEncryptionPadding.OaepSHA256); - var segments = new[] { encodedHeader, Encode(wrappedKey), Encode(iv), Encode(ciphertext), Encode(tag) }; - var decryptor = new JweDecryptor(keys); - var context = decryptor.Decrypt(string.Join(".", segments)).Context; - Assert.Equal("test-nonce", context.Nonce); - Assert.True(context.IsComplete); - Assert.False(JsonSerializer.SerializeToElement(context).TryGetProperty("IsComplete", out _)); - segments[0] = Encode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(JsonSerializer.Deserialize(header)))); - Assert.NotEqual(encodedHeader, segments[0]); - Assert.ThrowsAny(() => decryptor.Decrypt(string.Join(".", segments))); - } - - private static string Payload(string routing) => - "{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"encryptedDeliveryContext\":\"x\"," + routing + "}"; - - private static (Envelope? Envelope, string? Error) Parse(string routing) => - EnvelopeParser.Parse(JsonSerializer.Deserialize(Payload(routing))); - - private sealed class UnreadableBody : MemoryStream - { - public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => - ValueTask.FromException(new IOException("private read error")); - } -} - -internal sealed class TestKeys : IJweKeyProvider, IDisposable -{ - public RSA Rsa { get; } = RSA.Create(2048); - public int Calls { get; private set; } - public Exception? Error { get; set; } - public RSA GetPrivateKey(string? kid) - { - Calls++; - if (Error is not null) throw Error; - return Rsa; - } - public void Dispose() => Rsa.Dispose(); -} diff --git a/dotnet/tests/Epp.Otp.Tests.csproj b/dotnet/tests/Epp.Otp.Tests.csproj index 69ddf3b..5194b48 100644 --- a/dotnet/tests/Epp.Otp.Tests.csproj +++ b/dotnet/tests/Epp.Otp.Tests.csproj @@ -20,7 +20,7 @@ - + diff --git a/dotnet/tests/PayloadAndJweTests.cs b/dotnet/tests/PayloadAndJweTests.cs new file mode 100644 index 0000000..94a7eaf --- /dev/null +++ b/dotnet/tests/PayloadAndJweTests.cs @@ -0,0 +1,201 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace Epp.Otp.Tests; + +public class PayloadAndJweTests +{ + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void KeyVaultPemCertificateBundleDecrypts(bool certificateFirst, bool base64Encoded) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=EPP-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1)); + var publicPem = certificate.ExportCertificatePem(); + var privatePem = rsa.ExportPkcs8PrivateKeyPem(); + var bundle = certificateFirst ? $"{publicPem}\n{privatePem}" : $"{privatePem}\n{publicPem}"; + var env = new TestEnv + { + ["EPP_DECRYPTION_KEY_PEM"] = base64Encoded ? Convert.ToBase64String(Encoding.UTF8.GetBytes(bundle)) : bundle + }; + var decryptor = new JweDecryptor(env); + var compact = Jose.JWT.Encode("{\"nonce\":\"test-nonce\"}", rsa, + Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); + Assert.Equal( + "test-nonce", + decryptor.Decrypt(compact).Value.Nonce); + Assert.Equal( + "test-nonce", + decryptor.Decrypt(compact).Value.Nonce); + } + + [Theory] + [InlineData("1", "2", EntraOtpChannel.Sms, EntraOtpMode.Evaluation, 60)] + [InlineData("\"VOICE\"", "\"Live\"", EntraOtpChannel.Voice, EntraOtpMode.Live, null)] + [InlineData("\"1\"", "\"2\"", EntraOtpChannel.Sms, EntraOtpMode.Evaluation, null)] + public void NumericAndNamedRoutingDeserialize( + string channelJson, string modeJson, EntraOtpChannel channel, EntraOtpMode mode, int? ttl) + { + var ttlJson = ttl is null ? "" : $",\"ttlSeconds\":{ttl}"; + var payload = JsonSerializer.Deserialize( + $"{{\"type\":\"{EntraSendOtpPayload.SupportedType}\",\"encryptedDeliveryContext\":\"x\"," + + $"\"channel\":{channelJson},\"mode\":{modeJson}{ttlJson}}}"); + Assert.NotNull(payload); + Assert.Equal(channel, payload.Channel); + Assert.Equal(mode, payload.Mode); + Assert.Equal(ttl, payload.TtlSeconds); + Assert.Equal(channel == EntraOtpChannel.Sms ? "sms" : "voice", payload.ChannelName); + Assert.Equal(mode == EntraOtpMode.Evaluation, payload.IsEvaluation); + Assert.Null(payload.Validate()); + } + + [Theory] + [InlineData("channel", "\"email\"")] + [InlineData("channel", "true")] + [InlineData("channel", "null")] + [InlineData("channel", "1.5")] + [InlineData("channel", "{}")] + [InlineData("mode", "\"diagnostic\"")] + [InlineData("mode", "false")] + [InlineData("mode", "null")] + [InlineData("mode", "1.5")] + [InlineData("mode", "[]")] + public void RoutingConvertersRejectUnsupportedTokens(string property, string value) + { + var channel = property == "channel" ? value : "1"; + var mode = property == "mode" ? value : "1"; + var json = $"{{\"type\":\"{EntraSendOtpPayload.SupportedType}\",\"encryptedDeliveryContext\":\"x\"," + + $"\"channel\":{channel},\"mode\":{mode}}}"; + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Theory] + [InlineData(0, 1, "unsupported channel")] + [InlineData(3, 1, "unsupported channel")] + [InlineData(1, 0, "unsupported mode")] + [InlineData(1, 3, "unsupported mode")] + public void UndefinedNumericRoutingValuesFailSemanticValidation( + int channel, int mode, string expected) + { + var payload = JsonSerializer.Deserialize( + $"{{\"type\":\"{EntraSendOtpPayload.SupportedType}\",\"encryptedDeliveryContext\":\"x\"," + + $"\"channel\":{channel},\"mode\":{mode}}}"); + Assert.NotNull(payload); + Assert.Equal(expected, payload.Validate()); + } + + [Theory] + [InlineData("\"60\"")] + [InlineData("true")] + [InlineData("1.5")] + [InlineData("2147483648")] + public void TtlConverterRejectsNonIntegerValues(string value) + { + var json = $"{{\"type\":\"{EntraSendOtpPayload.SupportedType}\",\"encryptedDeliveryContext\":\"x\"," + + $"\"channel\":1,\"mode\":1,\"ttlSeconds\":{value}}}"; + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Fact] + public void ExplicitNullTtlIsEquivalentToOmittedTtl() + { + var payload = JsonSerializer.Deserialize( + $"{{\"type\":\"{EntraSendOtpPayload.SupportedType}\",\"encryptedDeliveryContext\":\"x\"," + + "\"channel\":1,\"mode\":1,\"ttlSeconds\":null}"); + Assert.NotNull(payload); + Assert.Null(payload.TtlSeconds); + Assert.Null(payload.Validate()); + } + + [Theory] + [InlineData("{\"channel\":1,\"mode\":1,\"encryptedDeliveryContext\":\"x\"}", "unsupported payload type")] + [InlineData("{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":1,\"mode\":1,\"encryptedDeliveryContext\":\" \"}", "encryptedDeliveryContext is required")] + [InlineData("{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"mode\":1,\"encryptedDeliveryContext\":\"x\"}", "unsupported channel")] + [InlineData("{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":1,\"encryptedDeliveryContext\":\"x\"}", "unsupported mode")] + [InlineData("{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":1,\"mode\":1,\"ttlSeconds\":0,\"encryptedDeliveryContext\":\"x\"}", "ttlSeconds expired")] + [InlineData("{\"type\":\"microsoft.mfa.otpDeliver.v1\",\"channel\":1,\"mode\":1,\"ttlSeconds\":-1,\"encryptedDeliveryContext\":\"x\"}", "ttlSeconds expired")] + public void BoundPayloadPreservesSemanticValidation(string json, string expected) + { + var payload = JsonSerializer.Deserialize(json); + Assert.NotNull(payload); + Assert.Equal(expected, payload.Validate()); + } + + [Fact] + public void BoundPayloadAllowsOptionalTtlAndIgnoresUnknownFields() + { + var payload = JsonSerializer.Deserialize( + $"{{\"type\":\"{EntraSendOtpPayload.SupportedType}\",\"channel\":\"SMS\",\"mode\":\"EVALUATION\"," + + "\"encryptedDeliveryContext\":\"x\",\"tenantId\":\"tenant\",\"diagnosticData\":{\"token\":\"private\"}}"); + Assert.NotNull(payload); + Assert.Null(payload.Validate()); + Assert.Null(payload.TtlSeconds); + Assert.Equal("tenant", payload.TenantId); + } + + [Fact] + public void RealJweRejectsTagTamperingAndMissingSegments() + { + using var keys = new TestKeys(); + var decryptor = new JweDecryptor(keys.Env); + var compact = Jose.JWT.Encode("{\"nonce\":\"private-nonce\"}", keys.Rsa, + Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); + var parts = compact.Split('.'); + parts[4] = (parts[4][0] == 'A' ? "B" : "A") + parts[4][1..]; + Assert.ThrowsAny(() => + decryptor.Decrypt(string.Join(".", parts))); + Assert.ThrowsAny(() => + decryptor.Decrypt(string.Join(".", parts.Take(4)))); + } + + [Fact] + public void JweAuthenticatesOriginalProtectedHeaderBytes() + { + using var keys = new TestKeys(); + const string header = "{ \"kid\" : \"test-key\", \"enc\" : \"A256GCM\", \"alg\" : \"RSA-OAEP-256\" }"; + static string Encode(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var encodedHeader = Encode(Encoding.UTF8.GetBytes(header)); + var key = RandomNumberGenerator.GetBytes(32); + var iv = RandomNumberGenerator.GetBytes(12); + var plaintext = Encoding.UTF8.GetBytes("{\"nonce\":\"test-nonce\",\"phoneNumber\":\"+15551234567\",\"message\":\"message\"}"); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[16]; + using var cipher = new AesGcm(key, tag.Length); + cipher.Encrypt(iv, plaintext, ciphertext, tag, Encoding.ASCII.GetBytes(encodedHeader)); + var wrappedKey = keys.Rsa.Encrypt(key, RSAEncryptionPadding.OaepSHA256); + var segments = new[] { encodedHeader, Encode(wrappedKey), Encode(iv), Encode(ciphertext), Encode(tag) }; + var decryptor = new JweDecryptor(keys.Env); + var context = decryptor.Decrypt(string.Join(".", segments)).Value; + Assert.Equal("test-nonce", context.Nonce); + Assert.True(context.IsComplete); + Assert.False(JsonSerializer.SerializeToElement(context).TryGetProperty("IsComplete", out _)); + segments[0] = Encode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(JsonSerializer.Deserialize(header)))); + Assert.NotEqual(encodedHeader, segments[0]); + Assert.ThrowsAny(() => + decryptor.Decrypt(string.Join(".", segments))); + } + +} + +internal sealed class TestKeys : IDisposable +{ + public RSA Rsa { get; } = RSA.Create(2048); + public TestEnv Env { get; } + + public TestKeys() + { + Env = new TestEnv + { + ["EPP_DECRYPTION_KEY_PEM"] = Rsa.ExportPkcs8PrivateKeyPem(), + }; + } + + public void Dispose() => Rsa.Dispose(); +} diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/SendOtpTests.cs similarity index 66% rename from dotnet/tests/EngineTests.cs rename to dotnet/tests/SendOtpTests.cs index 1f7acbf..eaaff27 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/SendOtpTests.cs @@ -11,7 +11,7 @@ namespace Epp.Otp.Tests; -public class EngineTests +public class SendOtpTests { private const string Phone = "+15551234567"; private const string Message = " Your code is 918273.\nDo not share. "; @@ -19,7 +19,18 @@ public class EngineTests private const string Kid = "private-jwe-kid"; private const string Correlation = "private-correlation"; private const string PrivateError = "private key/provider error: +15551234567 code 918273"; - + private static readonly string[] LiveEvents = + [ + "request_received", "payload_validated", "delivery_context_decrypted", "provider_selected", + "provider_credential_resolution_started", "provider_credential_resolved", + "provider_request_build_started", "provider_request_built", "provider_request_started", + "provider_response_received", "provider_response_processed", "response_prepared", "request_completed", + ]; + private static readonly string[] EvaluationEvents = + [ + "request_received", "payload_validated", "delivery_context_decrypted", + "evaluation_completed", "response_prepared", "request_completed", + ]; private static void ConfigureSoprano(HandlerRig rig) { rig.Env["EPP_PROVIDER_NAME"] = "soprano"; @@ -38,10 +49,9 @@ private static void ConfigureSoprano(HandlerRig rig) public async Task StartupPreparesOnlyCredentialsAndWarmRequestsReuseTheBundle() { using var rig = new HandlerRig(); - await rig.Engine.StartCredentialRefreshAsync(); + await rig.Credentials.StartAsync(default); Assert.Equal(1, rig.Secrets.Calls); Assert.Equal(0, rig.Http.Calls); - Assert.Equal(0, rig.Keys.Calls); AssertAccepted(await rig.Invoke("evaluation")); Assert.Equal(1, rig.Secrets.Calls); Assert.Equal(0, rig.Http.Calls); @@ -55,7 +65,8 @@ public async Task StartupWithoutProviderConfigurationKeepsEvaluationIndependent( { using var rig = new HandlerRig(); rig.Env.Clear(); - await rig.Engine.StartCredentialRefreshAsync(); + rig.Env["EPP_DECRYPTION_KEY_PEM"] = rig.Keys.Rsa.ExportPkcs8PrivateKeyPem(); + await rig.Credentials.StartAsync(default); Assert.Equal(0, rig.Secrets.Calls); Assert.Equal(0, rig.Http.Calls); AssertAccepted(await rig.Invoke("evaluation")); @@ -126,7 +137,7 @@ public async Task SopranoOAuthUsesSetupIdentitiesScopeAndOneBoundedExchange(stri Assert.Equal(3, rig.Http.Calls); Assert.Equal(0, rig.Secrets.Calls); Assert.DoesNotContain("private-provider-token", string.Join("\n", rig.Log.Messages)); - var credential = new ProviderCredential("oauth", AccessToken: "private-provider-token"); + var credential = new ProviderCredentials("oauth", AccessToken: "private-provider-token"); Assert.DoesNotContain("private-provider-token", JsonSerializer.Serialize(credential) + credential); } @@ -176,7 +187,7 @@ TokenCredential CreateProvider(string tenant, string application, Func record.GetProperty("logType").GetString() == "request"); + Assert.DoesNotContain( + rig.Log.Entries, + entry => entry.EventId.Name == "request_completed"); Assert.Equal("provider_request_started", rig.Log.Records.Last().GetProperty("eventName").GetString()); } finally @@ -221,33 +234,21 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() using var body = JsonDocument.Parse(rig.Http.Body!); Assert.Equal(Message, body.RootElement.GetProperty("messages")[0].GetProperty("content").GetProperty("text").GetString()); Assert.Equal(1, rig.Http.Calls); - var summary = Summary(rig); - using var fixtures = ReadContractFixtures(); - Assert.Equal(fixtures.RootElement.GetProperty("logging").GetProperty("liveEvents").EnumerateArray().Select(value => value.GetString()), - rig.Log.Records.Select(record => record.GetProperty("eventName").GetString())); - Assert.Equal("infobip", summary.GetProperty("providerName").GetString()); - Assert.Equal("apiKey", summary.GetProperty("providerAuthMode").GetString()); - Assert.Equal(200, summary.GetProperty("providerHttpStatus").GetInt32()); - Assert.Equal("PENDING", summary.GetProperty("providerStatus").GetString()); - Assert.Equal("Continue", summary.GetProperty("providerOutcome").GetString()); - Assert.True(summary.GetProperty("providerAttempted").GetBoolean()); - Assert.Equal("id", summary.GetProperty("providerMessageId").GetString()); - Assert.Equal(2500, summary.GetProperty("providerTimeoutMs").GetInt32()); - Assert.InRange(summary.GetProperty("providerElapsedMs").GetInt64(), 0, summary.GetProperty("elapsedMs").GetInt64()); + Assert.Equal(LiveEvents, rig.Log.Records.Select(record => record.GetProperty("eventName").GetString())); + var selected = rig.Log.Entry("provider_selected"); + Assert.Equal("infobip", selected.State["ProviderName"]); + Assert.Equal("apiKey", selected.State["AuthenticationMode"]); + var processed = rig.Log.Entry("provider_response_processed"); + Assert.Equal(200, processed.State["ProviderHttpStatus"]); + Assert.Equal("PENDING", processed.State["ProviderStatus"]); + Assert.Equal("Continue", processed.State["ProviderOutcome"]); + Assert.Equal(2500, rig.Log.Entry("provider_request_started").State["TimeoutMs"]); + Assert.Equal(Correlation, processed.Scope["MsCorrelationId"]); var log = string.Join("\n", rig.Log.Messages); - Assert.Equal(Correlation, summary.GetProperty("x-ms-correlation-id").GetString()); foreach (var value in new[] { Phone, "918273", "001234", Nonce, "private-api-key", "private-api-id" }) Assert.DoesNotContain(value, log); } - [Fact] - public void VoiceAllowsEmptyIntroAndKeepsDebugOutputPrivate() - { - var voice = new TextToVoice("", "001234", "en-US"); - Assert.True(voice.IsComplete); - Assert.Equal("TextToVoice", voice.ToString()); - } - [Fact] public async Task FailedHttpCannotAcknowledgeAnAcceptedBodyOrLeakProviderText() { @@ -258,6 +259,23 @@ public async Task FailedHttpCannotAcknowledgeAnAcceptedBodyOrLeakProviderText() Assert.Equal(1, rig.Http.Calls); } + [Theory] + [InlineData(Outcome.Continue, 503, 200)] + [InlineData(Outcome.Block, 200, 403)] + [InlineData(Outcome.Fail, 429, 429)] + [InlineData(Outcome.Fail, 401, 401)] + [InlineData(Outcome.Fail, 403, 401)] + [InlineData(Outcome.Fail, 400, 400)] + [InlineData(Outcome.Fail, 499, 400)] + [InlineData(Outcome.Fail, 200, 502)] + [InlineData(Outcome.Fail, 503, 502)] + public void FinalProviderOutcomeMapsToEndpointStatus( + Outcome outcome, int providerHttpStatus, int expected) + { + var result = new ProviderResult(outcome, true, providerHttpStatus); + Assert.Equal(expected, PhoneProviderBase.ToEndpointHttpStatus(result)); + } + [Fact] public async Task ResponseBodyTimeoutCancelsWithoutRetryOrSuccessNonce() { @@ -275,7 +293,7 @@ public async Task ResponseBodyTimeoutCancelsWithoutRetryOrSuccessNonce() Assert.Equal(200, summary.GetProperty("providerHttpStatus").GetInt32()); Assert.Equal(200, summary.GetProperty("providerTimeoutMs").GetInt32()); Assert.Equal(JsonValueKind.Null, summary.GetProperty("providerStatus").ValueKind); - Assert.InRange(summary.GetProperty("providerElapsedMs").GetInt64(), 0, summary.GetProperty("elapsedMs").GetInt64()); + Assert.True(summary.GetProperty("elapsedMs").GetInt64() >= 0); } [Fact] @@ -313,47 +331,42 @@ public async Task EvaluationValidatesRealJweWithoutProviderConfiguration() { using var rig = new HandlerRig(); rig.Env.Clear(); + rig.Env["EPP_DECRYPTION_KEY_PEM"] = rig.Keys.Rsa.ExportPkcs8PrivateKeyPem(); rig.Env["EPP_ENCRYPTION_KEY_ID"] = "configured-key-id"; AssertAccepted(await rig.Invoke("evaluation", tenantId: "untrusted-body-tenant")); Assert.Equal("encryption_key_id_mismatch", - JsonSerializer.Deserialize(Assert.Single(rig.Log.Entries, entry => entry.Level == LogLevel.Warning).Message) - .GetProperty("eventName").GetString()); - var summary = Summary(rig); - Assert.True(summary.GetProperty("encryptionKeyIdMismatch").GetBoolean()); - Assert.True(summary.GetProperty("evaluation").GetBoolean()); - Assert.Equal("evaluated", summary.GetProperty("result").GetString()); - Assert.False(summary.GetProperty("providerAttempted").GetBoolean()); - Assert.Equal(JsonValueKind.Null, summary.GetProperty("providerName").ValueKind); - Assert.Equal(JsonValueKind.Null, summary.GetProperty("providerHttpStatus").ValueKind); - Assert.Equal(JsonValueKind.Null, summary.GetProperty("providerElapsedMs").ValueKind); - Assert.Equal(JsonValueKind.Null, summary.GetProperty("providerCredentialSource").ValueKind); - Assert.Equal(JsonValueKind.Null, summary.GetProperty("providerCredentialElapsedMs").ValueKind); - Assert.Equal(JsonValueKind.Null, summary.GetProperty("providerEndpoint").ValueKind); - using var fixtures = ReadContractFixtures(); - Assert.Equal(fixtures.RootElement.GetProperty("logging").GetProperty("evaluationEvents").EnumerateArray().Select(value => value.GetString()), + Assert.Single(rig.Log.Entries, entry => entry.Level == LogLevel.Warning).EventId.Name); + Assert.True((bool)rig.Log.Entry("payload_validated").State["Evaluation"]!); + Assert.Equal("evaluated", rig.Log.Entry("request_completed").State["Result"]); + Assert.DoesNotContain( + rig.Log.Entries, + entry => entry.EventId.Name is "provider_selected" or "provider_request_started"); + Assert.Equal(EvaluationEvents, rig.Log.Records.Select(record => record.GetProperty("eventName").GetString()).Where(name => name != "encryption_key_id_mismatch")); foreach (var value in new[] { Kid, "configured-key-id", Phone, "918273", Nonce, "untrusted-body-tenant" }) Assert.DoesNotContain(value, string.Join("\n", rig.Log.Messages)); - Assert.Equal((1, 0, 0), (rig.Keys.Calls, rig.Secrets.Calls, rig.Http.Calls)); + Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls)); } [Fact] public async Task PrivateKeyErrorsStayGenericAndNeverReachTheProvider() { using var rig = new HandlerRig(); - rig.Keys.Error = new InvalidOperationException(PrivateError); + rig.Env["EPP_DECRYPTION_KEY_PEM"] = PrivateError; AssertFailure(rig, await rig.Invoke(), 400, "decryption_failed"); Assert.Equal(0, rig.Http.Calls); } [Theory] [InlineData("{", "decryption_failed", null)] - [InlineData("null", "bad_request", "incomplete delivery context")] - [InlineData("[]", "bad_request", "incomplete delivery context")] - [InlineData("{\"nonce\":123,\"phoneNumber\":\"phone\",\"message\":\"message\"}", "bad_request", "incomplete delivery context")] - [InlineData("{\"nonce\":\"nonce\",\"phoneNumber\":false,\"message\":\"message\"}", "bad_request", "incomplete delivery context")] - [InlineData("{\"nonce\":\"nonce\",\"phoneNumber\":\"phone\",\"message\":{}}", "bad_request", "incomplete delivery context")] - public async Task AuthenticatedPlaintextDistinguishesInvalidJsonFromIncompleteContext(string plaintext, string error, string? reason) + [InlineData("null", "decryption_failed", null)] + [InlineData("[]", "decryption_failed", null)] + [InlineData("{\"nonce\":123,\"phoneNumber\":\"phone\",\"message\":\"message\"}", "decryption_failed", null)] + [InlineData("{\"nonce\":\"nonce\",\"phoneNumber\":false,\"message\":\"message\"}", "decryption_failed", null)] + [InlineData("{\"nonce\":\"nonce\",\"phoneNumber\":\"phone\",\"message\":{}}", "decryption_failed", null)] + [InlineData("{\"nonce\":\"nonce\"}", "bad_request", "incomplete delivery context")] + public async Task AuthenticatedPlaintextUsesTypedDeliveryContextValidation( + string plaintext, string error, string? reason) { using var rig = new HandlerRig(); var result = await rig.Invoke(plaintext: plaintext); @@ -362,7 +375,7 @@ public async Task AuthenticatedPlaintextDistinguishesInvalidJsonFromIncompleteCo if (reason is null) Assert.False(body.TryGetProperty("reason", out _)); else Assert.Equal(reason, body.GetProperty("reason").GetString()); Assert.Equal(Correlation, body.GetProperty("correlationId").GetString()); - Assert.Equal((1, 0, 0), (rig.Keys.Calls, rig.Secrets.Calls, rig.Http.Calls)); + Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls)); } [Fact] @@ -370,31 +383,48 @@ public async Task SharedInvalidRequestsReturnSafeReasonsBeforeProviderIo() { using var rig = new HandlerRig(); using var fixtures = ReadContractFixtures(); + var semanticCases = 0; foreach (var fixture in fixtures.RootElement.GetProperty("badRequests").EnumerateArray()) { - var payload = new Dictionary + var bodyValues = new Dictionary { - ["type"] = EnvelopeParser.EnvelopeType, ["channel"] = 1, ["mode"] = 1, + ["type"] = EntraSendOtpPayload.SupportedType, ["channel"] = 1, ["mode"] = 1, ["encryptedDeliveryContext"] = "unused", ["ttlSeconds"] = 60, }; if (fixture.TryGetProperty("overrides", out var overrides)) - foreach (var property in overrides.EnumerateObject()) payload[property.Name] = property.Value; - var raw = fixture.TryGetProperty("rawBody", out var rawBody) ? rawBody.GetString()! : JsonSerializer.Serialize(payload); - var result = await rig.InvokeRaw(raw); + foreach (var property in overrides.EnumerateObject()) bodyValues[property.Name] = property.Value; + var raw = fixture.TryGetProperty("rawBody", out var rawBody) + ? rawBody.GetString()! + : JsonSerializer.Serialize(bodyValues); + EntraSendOtpPayload? payload; + try { payload = JsonSerializer.Deserialize(raw); } + catch (JsonException) { continue; } + if (payload is null) continue; + var validationError = payload.Validate(); + if (validationError is null) continue; + semanticCases++; + var expectedReason = fixture.GetProperty("name").GetString() == "wrong version" + ? "unsupported payload type" + : fixture.GetProperty("reason").GetString(); + Assert.Equal(expectedReason, validationError); + var result = await rig.InvokePayload(payload); AssertFailure(rig, result, 400, "bad_request"); var body = JsonSerializer.SerializeToElement(result.Value); Assert.False(string.IsNullOrEmpty(body.GetProperty("requestId").GetString())); Assert.Equal(3, body.EnumerateObject().Count()); - Assert.Equal(fixture.GetProperty("reason").GetString(), body.GetProperty("reason").GetString()); + Assert.Equal(validationError, body.GetProperty("reason").GetString()); } - Assert.Equal(0, rig.Keys.Calls); + Assert.True(semanticCases > 0); foreach (var changes in fixtures.RootElement.GetProperty("incompleteContexts").EnumerateArray()) { var result = await rig.Invoke("evaluation", deliveryOverrides: changes); - AssertFailure(rig, result, 400, "bad_request"); + var wrongType = changes.TryGetProperty("nonce", out var nonce) + && nonce.ValueKind == JsonValueKind.Number; + AssertFailure(rig, result, 400, wrongType ? "decryption_failed" : "bad_request"); var body = JsonSerializer.SerializeToElement(result.Value); - Assert.Equal(4, body.EnumerateObject().Count()); - Assert.Equal("incomplete delivery context", body.GetProperty("reason").GetString()); + Assert.Equal(wrongType ? 3 : 4, body.EnumerateObject().Count()); + if (!wrongType) + Assert.Equal("incomplete delivery context", body.GetProperty("reason").GetString()); Assert.Equal(Correlation, body.GetProperty("correlationId").GetString()); } Assert.Equal((0, 0), (rig.Secrets.Calls, rig.Http.Calls)); @@ -431,7 +461,7 @@ public async Task MicrosoftIdentifiersHaveExplicitSourcesWithoutSyntheticMicroso ["x-ms-client-request-id"] = "ms-request-id", ["x-ms-correlation-id"] = "ms-header-correlation-id", }; - foreach (var correlation in new[] { "ms-envelope-correlation-id", null }) + foreach (var correlation in new[] { "ms-payload-correlation-id", null }) { using var rig = new HandlerRig(); var result = await rig.Invoke(correlationId: correlation, headers: headers); @@ -439,8 +469,9 @@ public async Task MicrosoftIdentifiersHaveExplicitSourcesWithoutSyntheticMicroso var summary = Summary(rig); Assert.Equal(headers["x-ms-client-request-id"], summary.GetProperty("x-ms-client-request-id").GetString()); Assert.Equal(correlation ?? headers["x-ms-correlation-id"], summary.GetProperty("x-ms-correlation-id").GetString()); - Assert.Equal(correlation is null ? "header" : "envelope", summary.GetProperty("msCorrelationIdSource").GetString()); - Assert.Equal("header", rig.Log.Records.First().GetProperty("msCorrelationIdSource").GetString()); + Assert.Equal(correlation is null ? "header" : "payload", summary.GetProperty("msCorrelationIdSource").GetString()); + Assert.Equal(correlation is null ? "header" : "payload", + rig.Log.Records.First().GetProperty("msCorrelationIdSource").GetString()); Assert.Equal(JsonValueKind.Null, summary.GetProperty("functionInvocationId").ValueKind); Assert.DoesNotContain("PRIVATE", string.Join("\n", rig.Log.Messages)); } @@ -458,29 +489,28 @@ public async Task MicrosoftIdentifiersHaveExplicitSourcesWithoutSyntheticMicroso public void FunctionInvocationIdIsSeparateFromMicrosoftAndProviderIdentifiersInLogState() { var logger = new CapturingLogger(); - var log = new RequestLog(logger, "function-request", "function-invocation", "ms-request-id", "ms-header-correlation-id"); - var manifest = new TelesignProvider().Manifest; - log.ProviderSelected(manifest); - log.ProviderRequestStarted(1500); - log.ProviderResponseReceived(200); - log.ProviderRequestFinished(); - log.ProviderResponseProcessed(manifest, new ParsedResponse(true, 200, "provider-reference-id", - ProviderStatusCode: "3001", ProviderStatusDescription: PrivateError), Outcome.Continue, 200, true); - log.Complete(200); - var summary = logger.States.Last(); - Assert.Equal("function-request", summary["functionRequestId"]); - Assert.Equal("function-invocation", summary["functionInvocationId"]); - Assert.Equal("ms-request-id", summary["x-ms-client-request-id"]); - Assert.Equal("ms-header-correlation-id", summary["x-ms-correlation-id"]); - Assert.Equal("provider-reference-id", summary["providerMessageId"]); - Assert.Equal("3001", summary["providerStatus"]); + using (logger.BeginScope(new Dictionary + { + ["FunctionRequestId"] = "function-request", + ["FunctionInvocationId"] = "function-invocation", + ["MsClientRequestId"] = "ms-request-id", + ["MsCorrelationId"] = "ms-header-correlation-id", + })) + OtpLog.ProviderResponseProcessed( + logger, LogLevel.Information, 200, "3001", "Continue", null, 10); + + var entry = Assert.Single(logger.Entries); + Assert.Equal("function-request", entry.Scope["FunctionRequestId"]); + Assert.Equal("function-invocation", entry.Scope["FunctionInvocationId"]); + Assert.Equal("ms-request-id", entry.Scope["MsClientRequestId"]); + Assert.Equal("ms-header-correlation-id", entry.Scope["MsCorrelationId"]); + Assert.Equal("3001", entry.State["ProviderStatus"]); Assert.DoesNotContain("PRIVATE", string.Join("\n", logger.Messages)); Assert.DoesNotContain(PrivateError, string.Join("\n", logger.Messages)); } [Theory] - [InlineData("invalid_json", 400, "request_validation", "invalid JSON body", false)] - [InlineData("invalid_envelope", 400, "request_validation", "unsupported envelope type", false)] + [InlineData("invalid_payload", 400, "request_validation", "unsupported payload type", false)] [InlineData("decryption", 400, "decryption", "decryption_failed", false)] [InlineData("incomplete_context", 400, "delivery_context_validation", "incomplete delivery context", false)] [InlineData("unknown_provider", 400, "provider_selection", "unknown_provider", false)] @@ -491,14 +521,14 @@ public void FunctionInvocationIdIsSeparateFromMicrosoftAndProviderIdentifiersInL [InlineData("request_build", 502, "provider_request_build", "request_build_failed", false)] [InlineData("network", 502, "provider_transport", "provider_network_error", true)] [InlineData("response_parse", 502, "provider_response", "response_parse_failed", true)] - [InlineData("http_rejection", 429, "provider_response", "provider_rejected", true)] + [InlineData("http_rejection", 429, "provider_response", "provider_http_error", true)] public async Task FailuresEmitSeparateServiceEventsAndCompleteSummaries(string scenario, int status, string stage, string reason, bool attempted) { using var rig = new HandlerRig(); JsonElement? delivery = null; switch (scenario) { - case "decryption": rig.Keys.Error = new InvalidOperationException(PrivateError); break; + case "decryption": rig.Env["EPP_DECRYPTION_KEY_PEM"] = PrivateError; break; case "incomplete_context": delivery = JsonSerializer.SerializeToElement(new { nonce = "" }); break; case "unknown_provider": rig.Env["EPP_PROVIDER_NAME"] = "PRIVATE-UNKNOWN-PROVIDER"; break; case "wrong_channel": rig.Env["EPP_PROVIDER_CHANNEL"] = "voice"; break; @@ -513,7 +543,9 @@ public async Task FailuresEmitSeparateServiceEventsAndCompleteSummaries(string s rig.Http.Respond = _ => Task.FromException(new HttpRequestException(PrivateError)); break; case "response_parse": - rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"messages\":[{\"status\":{\"groupName\":123}}]}")); + rig.Http.Respond = _ => Task.FromResult(Json( + 200, + "{\"messages\":[{\"messageId\":123,\"status\":{\"groupName\":\"PENDING\"}}]}")); break; case "http_rejection": rig.Http.Respond = _ => Task.FromResult(Json(429, "{\"messages\":[{\"status\":{\"groupName\":\"PENDING\"}}]}")); @@ -526,8 +558,7 @@ public async Task FailuresEmitSeparateServiceEventsAndCompleteSummaries(string s }; var result = scenario switch { - "invalid_json" => await rig.InvokeRaw("{", headers), - "invalid_envelope" => await rig.InvokeRaw("{}", headers), + "invalid_payload" => await rig.Invoke(type: "wrong", headers: headers), _ => await rig.Invoke(deliveryOverrides: delivery, headers: headers), }; Assert.Equal(status, result.StatusCode); @@ -538,19 +569,20 @@ public async Task FailuresEmitSeparateServiceEventsAndCompleteSummaries(string s Assert.Equal(reason, summary.GetProperty("failureReason").GetString()); Assert.Equal("failed", summary.GetProperty("result").GetString()); Assert.Equal(headers["x-ms-client-request-id"], summary.GetProperty("x-ms-client-request-id").GetString()); - Assert.Equal(stage == "request_validation" ? "header" : "envelope", summary.GetProperty("msCorrelationIdSource").GetString()); + Assert.Equal(stage == "request_validation" ? "header" : "payload", summary.GetProperty("msCorrelationIdSource").GetString()); Assert.Equal(stage == "request_validation" ? headers["x-ms-correlation-id"] : Correlation, summary.GetProperty("x-ms-correlation-id").GetString()); Assert.Equal(attempted, summary.GetProperty("providerAttempted").GetBoolean()); Assert.Equal(attempted ? 1 : 0, rig.Http.Calls); - Assert.Equal(scenario == "http_rejection" ? "provider_response_processed" : stage + "_failed", - rig.Log.Records.ElementAt(rig.Log.Entries.Count - 3).GetProperty("eventName").GetString()); + var failure = rig.Log.Entries.Last(entry => entry.EventId.Name == "request_failed"); + Assert.Equal(stage, failure.State["FailureStage"]); + Assert.Equal(reason, failure.State["FailureReason"]); Assert.False(summary.GetProperty("responseContainsNonce").GetBoolean()); Assert.Equal(stage != "request_validation", summary.GetProperty("responseContainsCorrelationId").GetBoolean()); if (scenario == "credentials") { Assert.Equal("key_vault", summary.GetProperty("providerCredentialSource").GetString()); - Assert.InRange(summary.GetProperty("providerCredentialElapsedMs").GetInt64(), 0, summary.GetProperty("elapsedMs").GetInt64()); + Assert.Equal(JsonValueKind.Null, summary.GetProperty("providerCredentialElapsedMs").ValueKind); Assert.DoesNotContain(rig.Log.Records, record => record.GetProperty("eventName").GetString() == "provider_credential_resolved"); } Assert.Contains(rig.Log.Entries, entry => entry.Level == (status >= 500 ? LogLevel.Error : LogLevel.Warning)); @@ -576,39 +608,32 @@ public async Task SuccessfulLifecycleLogsAllowedBodyMetadataRawOAuthIdsAndEndpoi deliveryOverrides: JsonSerializer.SerializeToElement(new { diagnosticData = "PRIVATE-UNKNOWN-FIELD" }))); var summary = Summary(rig); var records = rig.Log.Records.ToArray(); - var validated = Assert.Single(records, record => record.GetProperty("eventName").GetString() == "envelope_validated"); - Assert.Equal(EnvelopeParser.EnvelopeType, validated.GetProperty("envelopeType").GetString()); - Assert.Equal(EnvelopeParser.EnvelopeType, summary.GetProperty("envelopeType").GetString()); + var validated = Assert.Single(records, record => record.GetProperty("eventName").GetString() == "payload_validated"); + Assert.Equal(EntraSendOtpPayload.SupportedType, validated.GetProperty("payloadType").GetString()); + Assert.Equal(EntraSendOtpPayload.SupportedType, summary.GetProperty("payloadType").GetString()); Assert.Equal(60, validated.GetProperty("ttlSeconds").GetInt32()); Assert.Equal(60, summary.GetProperty("ttlSeconds").GetInt32()); - Assert.True(validated.GetProperty("encryptedDeliveryContextPresent").GetBoolean()); var credentials = records.Where(record => record.GetProperty("eventName").GetString() is "provider_credential_resolution_started" or "provider_credential_resolved").ToArray(); Assert.Equal(2, credentials.Length); - foreach (var record in credentials.Append(summary)) - { - Assert.Equal("managed_identity_client_assertion", record.GetProperty("providerCredentialSource").GetString()); - Assert.Equal(rig.Env["EPP_PROVIDER_TENANT_ID"]!, record.GetProperty("providerTenantId").GetString()); - Assert.Equal(rig.Env["EPP_OUTBOUND_CLIENT_ID"]!, record.GetProperty("functionOutboundClientId").GetString()); - Assert.Equal(rig.Env["EPP_OUTBOUND_MI_CLIENT_ID"]!, record.GetProperty("functionOutboundManagedIdentityClientId").GetString()); - } + Assert.Equal( + "managed_identity_client_assertion", + credentials[0].GetProperty("credentialSource").GetString()); + Assert.Equal( + "managed_identity_client_assertion", + summary.GetProperty("providerCredentialSource").GetString()); Assert.InRange(summary.GetProperty("providerCredentialElapsedMs").GetInt64(), 0, summary.GetProperty("elapsedMs").GetInt64()); - var outbound = records.Where(record => record.GetProperty("eventName").GetString() - is "provider_request_built" or "provider_request_started"); - foreach (var record in outbound.Append(summary)) - { - Assert.Equal("POST", record.GetProperty("providerHttpMethod").GetString()); - Assert.Equal("https://provider.example/api/send", record.GetProperty("providerEndpoint").GetString()); - } var built = Assert.Single(records, record => record.GetProperty("eventName").GetString() == "provider_request_built"); + Assert.Equal("POST", built.GetProperty("httpMethod").GetString()); + Assert.Equal("https://provider.example/api/send", built.GetProperty("providerEndpoint").GetString()); + Assert.Equal("POST", summary.GetProperty("providerHttpMethod").GetString()); + Assert.Equal("https://provider.example/api/send", summary.GetProperty("providerEndpoint").GetString()); Assert.Equal("https", built.GetProperty("providerScheme").GetString()); Assert.False(built.GetProperty("redirectsAllowed").GetBoolean()); Assert.True(summary.GetProperty("responseContainsNonce").GetBoolean()); Assert.True(summary.GetProperty("responseContainsCorrelationId").GetBoolean()); Assert.DoesNotContain("PRIVATE", string.Join("\n", rig.Log.Messages)); - using var fixtures = ReadContractFixtures(); - Assert.Equal(fixtures.RootElement.GetProperty("logging").GetProperty("liveEvents").EnumerateArray().Select(value => value.GetString()), - records.Select(record => record.GetProperty("eventName").GetString())); + Assert.Equal(LiveEvents, records.Select(record => record.GetProperty("eventName").GetString())); } [Fact] @@ -626,9 +651,7 @@ public async Task ApiKeyLifecycleIdentifiesKeyVaultWithoutLoggingCredentials() Assert.Equal(JsonValueKind.Null, summary.GetProperty("functionOutboundManagedIdentityClientId").ValueKind); Assert.InRange(summary.GetProperty("providerCredentialElapsedMs").GetInt64(), 0, summary.GetProperty("elapsedMs").GetInt64()); Assert.Equal(2, rig.Secrets.Calls); - using var fixtures = ReadContractFixtures(); - Assert.Equal(fixtures.RootElement.GetProperty("logging").GetProperty("liveEvents").EnumerateArray().Select(value => value.GetString()), - rig.Log.Records.Select(record => record.GetProperty("eventName").GetString())); + Assert.Equal(LiveEvents, rig.Log.Records.Select(record => record.GetProperty("eventName").GetString())); } [Fact] @@ -637,6 +660,7 @@ public async Task RequestPreparationLogsTheAdapterFinalUrlNotTheConfiguredBase() using var rig = new HandlerRig(); rig.Env["EPP_PROVIDER_NAME"] = "sinch"; rig.Env["SINCH_VOICE_ENDPOINT"] = "https://different-provider.example/api/final"; + rig.Http.Respond = _ => Task.FromResult(Json(200, "{\"callId\":\"sinch-call-id\"}")); AssertAccepted(await rig.Invoke(channel: "voice")); var summary = Summary(rig); var expected = rig.Env["SINCH_VOICE_ENDPOINT"] + "/calling/v1/callouts"; @@ -650,32 +674,30 @@ public async Task RequestPreparationLogsTheAdapterFinalUrlNotTheConfiguredBase() public void RequestPreparationDoesNotLogArbitraryHttpMethods() { var logger = new CapturingLogger(); - var log = new RequestLog(logger, "function-request", null, null, null); - log.ProviderRequestBuilt("PRIVATE-METHOD", "https://provider.example/api/send"); + OtpLog.ProviderRequestBuilt(logger, "other", "https://provider.example/api/send"); var record = Assert.Single(logger.Records); - Assert.Equal("other", record.GetProperty("providerHttpMethod").GetString()); + Assert.Equal("other", record.GetProperty("httpMethod").GetString()); Assert.DoesNotContain("PRIVATE", string.Join("\n", logger.Messages)); } [Fact] - public async Task OptionalTtlStaysNullAndInvalidBodyValuesNeverEnterMetadata() + public async Task BoundPayloadWithOptionalTtlAndUnknownFieldsKeepsSafeMetadata() { using var rig = new HandlerRig(); var encrypted = Jose.JWT.Encode(JsonSerializer.Serialize(new { nonce = Nonce, phoneNumber = Phone, message = Message }), rig.Keys.Rsa, Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption.A256GCM); - var payload = new Dictionary + var payload = JsonSerializer.Deserialize(JsonSerializer.Serialize(new { - ["type"] = EnvelopeParser.EnvelopeType, ["channel"] = 1, ["mode"] = 2, - ["correlationId"] = Correlation, ["encryptedDeliveryContext"] = encrypted, - ["diagnosticData"] = new { token = "PRIVATE-UNKNOWN-FIELD" }, - }; - AssertAccepted(await rig.InvokeRaw(JsonSerializer.Serialize(payload))); + type = EntraSendOtpPayload.SupportedType, + channel = 1, + mode = 2, + correlationId = Correlation, + encryptedDeliveryContext = encrypted, + diagnosticData = new { token = "PRIVATE-UNKNOWN-FIELD" }, + })); + Assert.NotNull(payload); + AssertAccepted(await rig.InvokePayload(payload)); Assert.Equal(JsonValueKind.Null, Summary(rig).GetProperty("ttlSeconds").ValueKind); - payload["ttlSeconds"] = "PRIVATE-INVALID-TTL"; - Assert.Equal(400, (await rig.InvokeRaw(JsonSerializer.Serialize(payload))).StatusCode); - var summary = Summary(rig); - Assert.Equal(JsonValueKind.Null, summary.GetProperty("ttlSeconds").ValueKind); - Assert.Equal(JsonValueKind.Null, summary.GetProperty("envelopeType").ValueKind); Assert.DoesNotContain("PRIVATE", string.Join("\n", rig.Log.Messages)); } @@ -694,7 +716,16 @@ public async Task UnknownStatusesAndMalformedResponsesStayOutOfLogs(bool validJs var summary = Summary(rig); Assert.Equal("unmapped", summary.GetProperty("providerStatus").GetString()); Assert.Equal("Fail", summary.GetProperty("providerOutcome").GetString()); - Assert.Equal(validJson ? "provider_rejected" : "invalid_provider_json", summary.GetProperty("failureReason").GetString()); + Assert.Equal( + validJson ? "unrecognized_provider_status" : "invalid_provider_json", + summary.GetProperty("failureReason").GetString()); + Assert.Equal( + validJson ? "unrecognized_provider_status" : "invalid_provider_json", + rig.Log.Entry("provider_response_processed").State["FailureReason"]); + Assert.Equal( + !validJson, + rig.Log.Records.Any(record => + record.GetProperty("eventName").GetString() == "provider_response_invalid_json")); Assert.DoesNotContain("PRIVATE", string.Join("\n", rig.Log.Messages)); } @@ -709,18 +740,12 @@ public async Task InterleavedInvocationsKeepSeparateLogContexts() }; var results = await Task.WhenAll(rig.Invoke(correlationId: "correlation-first"), rig.Invoke(correlationId: "correlation-second")); Assert.All(results, result => Assert.Equal(200, result.StatusCode)); - var summaries = rig.Log.Records.Where(record => record.GetProperty("logType").GetString() == "request").ToArray(); - Assert.Equal(2, summaries.Length); - Assert.Equal(2, summaries.Select(record => record.GetProperty("functionRequestId").GetString()).Distinct().Count()); - using var fixtures = ReadContractFixtures(); - foreach (var summary in summaries) - { - var id = summary.GetProperty("functionRequestId").GetString(); - var events = rig.Log.Records.Where(record => record.GetProperty("functionRequestId").GetString() == id).ToArray(); - Assert.Equal(fixtures.RootElement.GetProperty("logging").GetProperty("liveEvents").EnumerateArray().Select(value => value.GetString()), - events.Select(record => record.GetProperty("eventName").GetString())); - Assert.All(events.Skip(1), record => Assert.Equal(summary.GetProperty("x-ms-correlation-id").GetString(), - record.GetProperty("x-ms-correlation-id").GetString())); + var requests = rig.Log.Entries.GroupBy(entry => entry.Scope["FunctionRequestId"]).ToArray(); + Assert.Equal(2, requests.Length); + foreach (var request in requests) + { + Assert.Equal(LiveEvents, request.Select(entry => entry.EventId.Name)); + Assert.Single(request.Select(entry => entry.Scope["MsCorrelationId"]).Distinct()); } Assert.DoesNotContain("PRIVATE", string.Join("\n", rig.Log.Messages)); } @@ -729,29 +754,13 @@ public async Task InterleavedInvocationsKeepSeparateLogContexts() public void SharedIdCasesPreserveRawValuesOrExplicitlyOmitInvalidMetadata() { using var fixtures = ReadContractFixtures(); - var fields = new[] { "x-ms-client-request-id", "x-ms-correlation-id", "providerTenantId", - "functionOutboundClientId", "functionOutboundManagedIdentityClientId", "providerMessageId" }; - var manifest = new SopranoProvider().Manifest; foreach (var fixture in fixtures.RootElement.GetProperty("logging").GetProperty("identifiers").EnumerateArray()) { var value = fixture.TryGetProperty("length", out var length) ? new string('A', length.GetInt32()) : fixture.GetProperty("value").GetString(); - var logger = new CapturingLogger(); - var log = new RequestLog(logger, "function-request", null, value, value); - log.ProviderSelected(manifest); - log.CredentialResolutionStarted(new AppConfig - { - ProviderTenantId = value, OutboundClientId = value, OutboundManagedIdentityClientId = value, - }); - log.ProviderResponseProcessed(manifest, new ParsedResponse(true, 200, value, "ENROUTE"), Outcome.Continue, 200, true); - log.Complete(200); - var summary = logger.Records.Last(); - foreach (var field in fields) - Assert.Equal(fixture.GetProperty("accepted").GetBoolean() ? value : null, summary.GetProperty(field).GetString()); - var expectedOmissions = fixture.TryGetProperty("omitted", out var omitted) && omitted.GetBoolean() ? fields : Array.Empty(); - Assert.Equal(expectedOmissions, summary.GetProperty("omittedIdFields").EnumerateArray().Select(item => item.GetString())); - Assert.All(logger.Records, record => Assert.DoesNotContain(record.EnumerateObject(), property => property.Name.EndsWith("Hash"))); - Assert.DoesNotContain("PRIVATE", string.Join("\n", logger.Messages)); + Assert.Equal( + fixture.GetProperty("accepted").GetBoolean() ? value : null, + OtpLog.SafeIdentifier(value)); } } @@ -761,39 +770,66 @@ public void SharedEndpointCasesKeepOnlySchemeHostPortAndApiPath() using var fixtures = ReadContractFixtures(); foreach (var fixture in fixtures.RootElement.GetProperty("logging").GetProperty("endpoints").EnumerateArray()) { - var logger = new CapturingLogger(); - var log = new RequestLog(logger, "function-request", null, null, null); - log.ProviderRequestBuilt("POST", fixture.GetProperty("url").GetString()!); - log.ProviderRequestStarted(1500); - log.Complete(200); - Assert.All(logger.Records, record => Assert.Equal(fixture.GetProperty("logged").GetString(), - record.GetProperty("providerEndpoint").GetString())); - Assert.DoesNotContain("PRIVATE", string.Join("\n", logger.Messages)); + var uri = new Uri(fixture.GetProperty("url").GetString()!, UriKind.Absolute); + Assert.Equal( + fixture.GetProperty("logged").GetString(), + PhoneProviderBase.SanitizeEndpoint(uri)); } } private static JsonElement Summary(HandlerRig rig) { - var summary = rig.Log.Records.Last(); - Assert.Equal("request", summary.GetProperty("logType").GetString()); - Assert.Equal("request_completed", summary.GetProperty("eventName").GetString()); - using var fixtures = ReadContractFixtures(); - Assert.Equal(fixtures.RootElement.GetProperty("logging").GetProperty("summaryFields").EnumerateArray() - .Select(value => value.GetString()).OrderBy(value => value), - summary.EnumerateObject().Select(property => property.Name).OrderBy(value => value)); - var events = rig.Log.Records.Where(record => record.GetProperty("functionRequestId").GetString() - == summary.GetProperty("functionRequestId").GetString()).ToArray(); - Assert.Single(events, record => record.GetProperty("logType").GetString() == "request"); - var prepared = Assert.Single(events, record => record.GetProperty("eventName").GetString() == "response_prepared"); - Assert.Equal("response_prepared", events[^2].GetProperty("eventName").GetString()); - Assert.Equal(summary.GetProperty("httpStatus").GetInt32(), prepared.GetProperty("httpStatus").GetInt32()); - Assert.Equal(summary.GetProperty("httpStatus").GetInt32() == 200, summary.GetProperty("responseContainsNonce").GetBoolean()); - Assert.Equal(summary.GetProperty("responseContainsNonce").GetBoolean(), prepared.GetProperty("responseContainsNonce").GetBoolean()); - Assert.Equal(summary.GetProperty("responseContainsCorrelationId").GetBoolean(), prepared.GetProperty("responseContainsCorrelationId").GetBoolean()); - Assert.All(events[..^1], record => Assert.Equal("service", record.GetProperty("logType").GetString())); - Assert.All(events, record => Assert.Equal("SendOtp", record.GetProperty("functionName").GetString())); - Assert.Equal(summary.EnumerateObject().Select(property => property.Name).OrderBy(value => value), - rig.Log.States.Last().Keys.OrderBy(value => value)); + var entries = rig.Log.Entries.ToArray(); + var scope = entries.Last().Scope; + var state = entries + .SelectMany(entry => entry.State) + .GroupBy(pair => pair.Key) + .ToDictionary(group => group.Key, group => group.Last().Value); + object? Value(string key) => state.GetValueOrDefault(key); + object? Scope(string key) => scope.GetValueOrDefault(key); + var failure = entries.LastOrDefault(entry => entry.EventId.Name == "request_failed"); + var prepared = entries.Single(entry => entry.EventId.Name == "response_prepared"); + var completed = entries.Single(entry => entry.EventId.Name == "request_completed"); + var summary = JsonSerializer.SerializeToElement(new Dictionary + { + ["functionName"] = Scope("FunctionName"), + ["functionRequestId"] = Scope("FunctionRequestId"), + ["functionInvocationId"] = Scope("FunctionInvocationId"), + ["x-ms-client-request-id"] = Scope("MsClientRequestId"), + ["x-ms-correlation-id"] = Scope("MsCorrelationId"), + ["msCorrelationIdSource"] = Scope("MsCorrelationIdSource"), + ["channel"] = Value("Channel"), + ["evaluation"] = Value("Evaluation"), + ["payloadType"] = Value("PayloadType"), + ["ttlSeconds"] = Value("TtlSeconds"), + ["encryptionKeyIdMismatch"] = entries.Any(entry => entry.EventId.Name == "encryption_key_id_mismatch"), + ["providerName"] = Value("ProviderName"), + ["providerAuthMode"] = Value("AuthenticationMode"), + ["providerAttempted"] = entries.Any(entry => entry.EventId.Name == "provider_request_started"), + ["providerCredentialSource"] = Value("CredentialSource"), + ["providerCredentialElapsedMs"] = entries + .LastOrDefault(entry => entry.EventId.Name == "provider_credential_resolved") + ?.State.GetValueOrDefault("ElapsedMs"), + ["providerTenantId"] = null, + ["functionOutboundClientId"] = null, + ["functionOutboundManagedIdentityClientId"] = null, + ["providerHttpMethod"] = Value("HttpMethod"), + ["providerEndpoint"] = Value("ProviderEndpoint"), + ["providerHttpStatus"] = Value("ProviderHttpStatus"), + ["providerStatus"] = Value("ProviderStatus"), + ["providerOutcome"] = Value("ProviderOutcome"), + ["providerElapsedMs"] = entries + .LastOrDefault(entry => entry.EventId.Name == "provider_response_processed") + ?.State.GetValueOrDefault("ElapsedMs"), + ["providerTimeoutMs"] = Value("TimeoutMs"), + ["failureStage"] = failure?.State.GetValueOrDefault("FailureStage"), + ["failureReason"] = failure?.State.GetValueOrDefault("FailureReason"), + ["httpStatus"] = completed.State["HttpStatus"], + ["result"] = completed.State["Result"], + ["elapsedMs"] = completed.State["ElapsedMs"], + ["responseContainsNonce"] = prepared.State["ContainsNonce"], + ["responseContainsCorrelationId"] = prepared.State["ContainsCorrelationId"], + }); foreach (var value in new[] { PrivateError, Phone, "918273", Nonce, "private-api-key", "private-api-id" }) Assert.DoesNotContain(value, string.Join("\n", rig.Log.Messages)); return summary; @@ -833,7 +869,8 @@ private sealed class HandlerRig : IDisposable public TestHttp Http { get; } = new(); public TestKeys Keys { get; } = new(); public CapturingLogger Log { get; } = new(); - public DispatchEngine Engine { get; } + public SendOtp Function { get; } + public CredentialTokenService Credentials { get; } public HandlerRig(Func? createIdentity = null, Func>, TokenCredential>? createOAuth = null) { @@ -842,43 +879,59 @@ public HandlerRig(Func? createIdentity = null, ["EPP_PROVIDER_NAME"] = "infobip", ["EPP_PROVIDER_ENDPOINT"] = "https://provider.example", ["EPP_PROVIDER_TIMEOUT_MS"] = "2500", + ["EPP_DECRYPTION_KEY_PEM"] = Keys.Rsa.ExportPkcs8PrivateKeyPem(), + }; + PhoneProviderBase[] providers = + { + new InfobipProvider(Secrets), + new TelesignProvider(Secrets), + new SopranoProvider( + createIdentity ?? (_ => throw new InvalidOperationException("Unexpected managed identity")), + createOAuth ?? ((_, _, _) => throw new InvalidOperationException("Unexpected OAuth"))), + new SinchProvider(Secrets), }; - var registry = new ProviderRegistry(new IProviderAdapter[] - { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() }); - var engine = createIdentity is null ? new DispatchEngine(registry, Secrets, Http, Env) - : new DispatchEngine(registry, Secrets, Http, Env, createIdentity, createOAuth!); - Engine = engine; - _function = new SendOtp(engine, - new JweDecryptor(Keys), Env, Log); + Credentials = new CredentialTokenService(providers, Env); + _function = new SendOtp(providers, Credentials, Http, new JweDecryptor(Env), Env, Log); + Function = _function; } public async Task Invoke(object? mode = null, string channel = "sms", string? tenantId = null, Jose.JweAlgorithm algorithm = Jose.JweAlgorithm.RSA_OAEP_256, Jose.JweEncryption encryption = Jose.JweEncryption.A256GCM, JsonElement? deliveryOverrides = null, - string? plaintext = null, string? correlationId = Correlation, Dictionary? headers = null) + string? plaintext = null, string? correlationId = Correlation, Dictionary? headers = null, + string? type = EntraSendOtpPayload.SupportedType, int? ttlSeconds = 60) { var context = new Dictionary { ["nonce"] = Nonce, ["phoneNumber"] = Phone, ["message"] = Message }; if (deliveryOverrides is { } changes) foreach (var property in changes.EnumerateObject()) context[property.Name] = property.Value; var encrypted = Jose.JWT.Encode(plaintext ?? JsonSerializer.Serialize(context), Keys.Rsa, algorithm, encryption, extraHeaders: new Dictionary { ["kid"] = Kid }); - return await InvokeRaw(JsonSerializer.Serialize(new + return await InvokePayload(new EntraSendOtpPayload { - type = EnvelopeParser.EnvelopeType, tenantId, correlationId, channel, mode = mode ?? "live", - ttlSeconds = 60, encryptedDeliveryContext = encrypted, - }), headers); + Type = type, + TenantId = tenantId, + CorrelationId = correlationId, + Channel = channel.Equals("voice", StringComparison.OrdinalIgnoreCase) + ? EntraOtpChannel.Voice + : EntraOtpChannel.Sms, + Mode = mode is string text && text.Equals("evaluation", StringComparison.OrdinalIgnoreCase) + || mode is int code && code == 2 + ? EntraOtpMode.Evaluation + : EntraOtpMode.Live, + TtlSeconds = ttlSeconds, + EncryptedDeliveryContext = encrypted, + }, headers); } - public async Task InvokeRaw(string body, Dictionary? headers = null) + public async Task InvokePayload( + EntraSendOtpPayload payload, Dictionary? headers = null) { - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); var request = new DefaultHttpContext().Request; request.Method = "POST"; request.ContentType = "application/json"; - request.Body = stream; if (headers is not null) foreach (var (key, value) in headers) request.Headers[key] = value; - return Assert.IsAssignableFrom(await _function.Run(request)); + return Assert.IsAssignableFrom(await _function.Run(request, payload)); } - public void Dispose() { Engine.Dispose(); Keys.Dispose(); Http.Dispose(); } + public void Dispose() { Credentials.Dispose(); Keys.Dispose(); Http.Dispose(); } } private sealed class TestSecrets : ISecretResolver @@ -914,20 +967,97 @@ protected override async Task SendAsync(HttpRequestMessage private sealed class CapturingLogger : ILogger { - private readonly object _gate = new(); - public List<(LogLevel Level, string Message)> Entries { get; } = new(); - public List> States { get; } = new(); + private readonly AsyncLocal _currentScope = new(); + public List Entries { get; } = []; public IEnumerable Messages => Entries.Select(entry => entry.Message); - public IEnumerable Records => Messages.Select(message => JsonSerializer.Deserialize(message)); - public IDisposable? BeginScope(TState state) where TState : notnull => null; + public IEnumerable> States => + Entries.Select(entry => entry.State); + public IEnumerable Records => Entries.Select(entry => + { + var values = new Dictionary(StringComparer.Ordinal) + { + ["eventName"] = entry.EventId.Name, + ["logType"] = "service", + }; + foreach (var pair in entry.Scope.Concat(entry.State)) + values[ToRecordKey(pair.Key)] = pair.Value; + if (entry.EventId.Name == "provider_request_built") + { + values["providerScheme"] = Uri.TryCreate( + entry.State.GetValueOrDefault("ProviderEndpoint") as string, + UriKind.Absolute, + out var uri) + ? uri.Scheme + : null; + values["redirectsAllowed"] = false; + } + return JsonSerializer.SerializeToElement(values); + }); + + public CapturedLogEntry Entry(string eventName) => + Assert.Single(Entries, entry => entry.EventId.Name == eventName); + + public IDisposable BeginScope(TState state) where TState : notnull + { + var node = new ScopeNode( + _currentScope.Value, + ToDictionary(state), + () => _currentScope.Value = _currentScope.Value?.Parent); + _currentScope.Value = node; + return node; + } + public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel level, EventId id, TState state, Exception? error, Func formatter) { - lock (_gate) + var scopes = new Dictionary(StringComparer.Ordinal); + var nodes = new Stack(); + for (var node = _currentScope.Value; node is not null; node = node.Parent) + nodes.Push(node); + foreach (var node in nodes) + foreach (var pair in node.Values) + scopes[pair.Key] = pair.Value; + + lock (Entries) + Entries.Add(new CapturedLogEntry( + level, + id, + formatter(state, error), + ToDictionary(state), + scopes, + error)); + } + + private static Dictionary ToDictionary(TState state) => + state is IEnumerable> pairs + ? pairs.Where(pair => pair.Key != "{OriginalFormat}") + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal); + + private static string ToRecordKey(string value) => + value switch { - Entries.Add((level, formatter(state, error) + (error?.ToString() ?? ""))); - States.Add(Assert.IsAssignableFrom>>(state).ToDictionary(pair => pair.Key, pair => pair.Value)); - } + "MsClientRequestId" => "x-ms-client-request-id", + "MsCorrelationId" => "x-ms-correlation-id", + _ => char.ToLowerInvariant(value[0]) + value[1..], + }; + + public sealed record CapturedLogEntry( + LogLevel Level, + EventId EventId, + string Message, + IReadOnlyDictionary State, + IReadOnlyDictionary Scope, + Exception? Error); + + private sealed class ScopeNode( + ScopeNode? parent, + IReadOnlyDictionary values, + Action dispose) : IDisposable + { + public ScopeNode? Parent { get; } = parent; + public IReadOnlyDictionary Values { get; } = values; + public void Dispose() => dispose(); } }