Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/voice add webhook validation method #62

Merged
merged 5 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 4 additions & 14 deletions src/Sinch/SinchClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,8 @@ public ISinchVerificationClient Verification(string appKey, string appSecret,
/// <param name="appKey"></param>
/// <param name="appSecret"></param>
/// <param name="callingRegion">See <see cref="CallingRegion" />. Defaults to <see cref="CallingRegion.Global" /></param>
/// <param name="authStrategy">
/// Choose which authentication to use.
/// Defaults to Application Sign request and it's a recommended approach.
/// </param>
/// <returns></returns>
public ISinchVoiceClient Voice(string appKey, string appSecret, CallingRegion? callingRegion = null,
AuthStrategy authStrategy = AuthStrategy.ApplicationSign);
public ISinchVoiceClient Voice(string appKey, string appSecret, CallingRegion? callingRegion = null);
}

public class SinchClient : ISinchClient
Expand Down Expand Up @@ -271,27 +266,22 @@ public ISinchVerificationClient Verification(string appKey, string appSecret,

/// <inheritdoc />
public ISinchVoiceClient Voice(string appKey, string appSecret,
CallingRegion? callingRegion = default,
AuthStrategy authStrategy = AuthStrategy.ApplicationSign)
CallingRegion? callingRegion = default)
JPPortier marked this conversation as resolved.
Show resolved Hide resolved
{
if (string.IsNullOrEmpty(appKey))
throw new ArgumentNullException(nameof(appKey), "The value should be present");

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);
asein-sinch marked this conversation as resolved.
Show resolved Hide resolved

callingRegion ??= CallingRegion.Global;

var http = new Http(auth, _httpClient, _loggerFactory?.Create<IHttp>(), JsonNamingPolicy.CamelCase);
return new SinchVoiceClient(
new Uri(_apiUrlOverrides?.VoiceUrl ?? string.Format(VoiceApiUrlTemplate, callingRegion.Value)),
_loggerFactory, http);
_loggerFactory, http, (auth as ApplicationSignedAuth)!);
}

private void ValidateCommonCredentials()
Expand Down
73 changes: 72 additions & 1 deletion src/Sinch/Voice/SinchVoiceClient.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -30,14 +37,30 @@ public interface ISinchVoiceClient
/// You can use the API to manage features of applications in your project.
/// </summary>
ISinchVoiceApplications Applications { get; }

/// <summary>
/// Validates callback request.
/// </summary>
/// <param name="path"></param>
/// <param name="headers"></param>
/// <param name="body"></param>
/// <param name="method"></param>
/// <returns>True, if produced signature match with that of a header.</returns>
bool ValidateAuthenticationHeader(HttpMethod method, string path, Dictionary<string, StringValues> headers,
JsonObject body);
}

/// <inheritdoc />
internal class SinchVoiceClient : ISinchVoiceClient
{
private readonly ApplicationSignedAuth _applicationSignedAuth;
private readonly ILoggerAdapter<ISinchVoiceClient>? _logger;

public SinchVoiceClient(Uri baseAddress, LoggerFactory? loggerFactory,
IHttp http)
IHttp http, ApplicationSignedAuth applicationSignedAuth)
{
_applicationSignedAuth = applicationSignedAuth;
_logger = loggerFactory?.Create<ISinchVoiceClient>();
Callouts = new SinchCallout(loggerFactory?.Create<ISinchVoiceCallout>(), baseAddress, http);
Calls = new SinchCalls(loggerFactory?.Create<ISinchVoiceCalls>(), baseAddress, http);
Conferences = new SinchConferences(loggerFactory?.Create<ISinchVoiceConferences>(), baseAddress, http);
Expand All @@ -55,5 +78,53 @@ public SinchVoiceClient(Uri baseAddress, LoggerFactory? loggerFactory,

/// <inheritdoc />
public ISinchVoiceApplications Applications { get; }

public bool ValidateAuthenticationHeader(HttpMethod method, string path,
Dictionary<string, StringValues> headers, JsonObject body)
{
var headersCaseInsensitive =
new Dictionary<string, StringValues>(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 signature = authSignature.Split(' ').Skip(1).FirstOrDefault();

if (signature == null)
{
_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;
}
}
}
38 changes: 38 additions & 0 deletions tests/Sinch.Tests/Voice/HooksTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json.Nodes;
using FluentAssertions;
using Microsoft.Extensions.Primitives;
using Xunit;

namespace Sinch.Tests.Voice
{
public class HooksTests
{
[Fact]
public void ValidateRequest()
JPPortier marked this conversation as resolved.
Show resolved Hide resolved
{
// https://developers.sinch.com/docs/voice/api-reference/authentication/callback-signed-request/
// full path: "https://callbacks.yourdomain.com/sinch/callback/ace"
var voiceClient = new SinchClient(default, default, default).Voice("669E367E-6BBA-48AB-AF15-266871C28135",
"BeIukql3pTKJ8RGL5zo0DA==");
var body =
"{\"event\":\"ace\",\"callid\":\"822aa4b7-05b4-4d83-87c7-1f835ee0b6f6_257\",\"timestamp\":\"2014-09-24T10:59:41Z\",\"version\":1}";

voiceClient.ValidateAuthenticationHeader(HttpMethod.Post, "/sinch/callback/ace",
new Dictionary<string, StringValues>()
{
{ "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();
}
}
}
2 changes: 1 addition & 1 deletion tests/Sinch.Tests/e2e/Voice/VoiceTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading