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);
}
}
}