diff --git a/src/Sinch/SinchClient.cs b/src/Sinch/SinchClient.cs index c213c47..f87e3f3 100644 --- a/src/Sinch/SinchClient.cs +++ b/src/Sinch/SinchClient.cs @@ -109,13 +109,8 @@ public ISinchVerificationClient Verification(string appKey, string appSecret, /// /// /// See . Defaults to - /// - /// Choose which authentication to use. - /// Defaults to Application Sign request and it's a recommended approach. - /// /// - public ISinchVoiceClient Voice(string appKey, string appSecret, VoiceRegion? voiceRegion = null, - AuthStrategy authStrategy = AuthStrategy.ApplicationSign); + public ISinchVoiceClient Voice(string appKey, string appSecret, VoiceRegion? voiceRegion = null); } public class SinchClient : ISinchClient @@ -271,8 +266,7 @@ public ISinchVerificationClient Verification(string appKey, string appSecret, /// public ISinchVoiceClient Voice(string appKey, string appSecret, - VoiceRegion? voiceRegion = default, - AuthStrategy authStrategy = AuthStrategy.ApplicationSign) + VoiceRegion? voiceRegion = default) { if (string.IsNullOrEmpty(appKey)) throw new ArgumentNullException(nameof(appKey), "The value should be present"); @@ -280,18 +274,14 @@ public ISinchVoiceClient Voice(string appKey, string appSecret, if (string.IsNullOrEmpty(appSecret)) throw new ArgumentNullException(nameof(appSecret), "The value should be present"); - ISinchAuth auth; - if (authStrategy == AuthStrategy.ApplicationSign) - auth = new ApplicationSignedAuth(appKey, appSecret); - else - auth = new BasicAuth(appKey, appSecret); + ISinchAuth auth = new ApplicationSignedAuth(appKey, appSecret); voiceRegion ??= VoiceRegion.Global; var http = new Http(auth, _httpClient, _loggerFactory?.Create(), JsonNamingPolicy.CamelCase); return new SinchVoiceClient( new Uri(_apiUrlOverrides?.VoiceUrl ?? string.Format(VoiceApiUrlTemplate, voiceRegion.Value)), - _loggerFactory, http); + _loggerFactory, http, (auth as ApplicationSignedAuth)!); } private void ValidateCommonCredentials() diff --git a/src/Sinch/Voice/SinchVoiceClient.cs b/src/Sinch/Voice/SinchVoiceClient.cs index fa3c280..6eb30e9 100644 --- a/src/Sinch/Voice/SinchVoiceClient.cs +++ b/src/Sinch/Voice/SinchVoiceClient.cs @@ -1,4 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Primitives; +using Sinch.Auth; using Sinch.Core; using Sinch.Logger; using Sinch.Voice.Applications; @@ -30,14 +37,30 @@ public interface ISinchVoiceClient /// You can use the API to manage features of applications in your project. /// ISinchVoiceApplications Applications { get; } + + /// + /// Validates callback request. + /// + /// + /// + /// + /// + /// True, if produced signature match with that of a header. + bool ValidateAuthenticationHeader(HttpMethod method, string path, Dictionary headers, + JsonObject body); } /// internal class SinchVoiceClient : ISinchVoiceClient { + private readonly ApplicationSignedAuth _applicationSignedAuth; + private readonly ILoggerAdapter? _logger; + public SinchVoiceClient(Uri baseAddress, LoggerFactory? loggerFactory, - IHttp http) + IHttp http, ApplicationSignedAuth applicationSignedAuth) { + _applicationSignedAuth = applicationSignedAuth; + _logger = loggerFactory?.Create(); Callouts = new SinchCallout(loggerFactory?.Create(), baseAddress, http); Calls = new SinchCalls(loggerFactory?.Create(), baseAddress, http); Conferences = new SinchConferences(loggerFactory?.Create(), baseAddress, http); @@ -55,5 +78,62 @@ public SinchVoiceClient(Uri baseAddress, LoggerFactory? loggerFactory, /// public ISinchVoiceApplications Applications { get; } + + public bool ValidateAuthenticationHeader(HttpMethod method, string path, + Dictionary headers, JsonObject body) + { + var headersCaseInsensitive = + new Dictionary(headers, StringComparer.InvariantCultureIgnoreCase); + + if (!headersCaseInsensitive.TryGetValue("authorization", out var authHeaderValue)) + { + _logger?.LogDebug("Failed to validate auth header. Authorization header is missing."); + return false; + } + + if (authHeaderValue.Count == 0) + { + _logger?.LogDebug("Failed to validate auth header. Authorization header values is missing."); + return false; + } + + var authSignature = authHeaderValue.FirstOrDefault(); + if (authSignature == null) + { + _logger?.LogDebug("Failed to validate auth header. Authorization header value is null."); + return false; + } + + const string timestampHeader = "x-timestamp"; + var bytesBody = JsonSerializer.SerializeToUtf8Bytes(body); + var contentType = headersCaseInsensitive.GetValueOrDefault("content-type"); + var timestamp = headersCaseInsensitive.GetValueOrDefault(timestampHeader, string.Empty); + var calculatedSignature = + _applicationSignedAuth.GetSignedAuth(bytesBody, method.Method, path, + string.Join(':', timestampHeader, timestamp), contentType); + var splitAuthHeader = authSignature.Split(' '); + + if (splitAuthHeader.FirstOrDefault() != "application") + { + _logger?.LogDebug( + "Failed to validate auth header. Authorization header not starting from 'application'."); + return false; + } + + var signature = splitAuthHeader.Skip(1).FirstOrDefault(); + + if (string.IsNullOrEmpty(signature)) + { + _logger?.LogDebug("Failed to validate auth header. Signature is missing from the header."); + return false; + } + + _logger?.LogDebug("{CalculatedSignature}", calculatedSignature); + _logger?.LogDebug("{AuthorizationSignature}", signature); + + var isValidSignature = string.Equals(signature, calculatedSignature, StringComparison.Ordinal); + _logger?.LogInformation("The signature was validated with {success}", isValidSignature); + return isValidSignature; + } } } diff --git a/tests/Sinch.Tests/Voice/HooksTests.cs b/tests/Sinch.Tests/Voice/HooksTests.cs new file mode 100644 index 0000000..75bc785 --- /dev/null +++ b/tests/Sinch.Tests/Voice/HooksTests.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json.Nodes; +using FluentAssertions; +using Microsoft.Extensions.Primitives; +using Sinch.Voice; +using Xunit; + +namespace Sinch.Tests.Voice +{ + public class HooksTests + { + private readonly ISinchVoiceClient _voiceClient = new SinchClient(default, default, default).Voice( + "669E367E-6BBA-48AB-AF15-266871C28135", + "BeIukql3pTKJ8RGL5zo0DA=="); + + private string _body = + "{\"event\":\"ace\",\"callid\":\"822aa4b7-05b4-4d83-87c7-1f835ee0b6f6_257\",\"timestamp\":\"2014-09-24T10:59:41Z\",\"version\":1}"; + + [Fact] + public void ValidateRequest() + { + // https://developers.sinch.com/docs/voice/api-reference/authentication/callback-signed-request/ + // full path: "https://callbacks.yourdomain.com/sinch/callback/ace" + + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeTrue(); + } + + [Fact] + public void FailIfInvalidAuthHeaderValue() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:bdJO/XUVvIsb5SlZAKmvfw==" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailIfAuthHeaderMissing() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailIfInvalidPath() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/not/that/path", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotThatHttpMethod() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Get, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotThatTimestamp() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2019-11-03T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotThatContentType() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "text" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotThatBody() + { + _body = JsonNode.Parse("{\"hello\": \"world\"}")!.ToJsonString(); + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "application 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + + [Fact] + public void FailNotApplicationHeader() + { + _voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace", + new Dictionary() + { + { "x-timestamp", new[] { "2014-09-24T10:59:41Z" } }, + { "content-type", new[] { "application/json" } }, + { + "authorization", + new[] + { + "authorization 669E367E-6BBA-48AB-AF15-266871C28135:Tg6fMyo8mj9pYfWQ9ssbx3Tc1BNC87IEygAfLbJqZb4=" + } + } + } + , JsonNode.Parse(_body)!.AsObject()).Should().BeFalse(); + } + } +} diff --git a/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs b/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs index 881f3ba..3215c52 100644 --- a/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs +++ b/tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs @@ -9,7 +9,7 @@ public class VoiceTestBase : TestBase protected VoiceTestBase() { - VoiceClient = SinchClientMockServer.Voice("app_key", "app_secret", default, AuthStrategy.Basic); + VoiceClient = SinchClientMockServer.Voice("669E367E-6BBA-48AB-AF15-266871C28135", "BeIukql3pTKJ8RGL5zo0DA==", default); } } }