Skip to content

Instantly share code, notes, and snippets.

@tompetko
Last active June 27, 2019 07:23
Show Gist options
  • Save tompetko/8169b161375cd23618280dbcd00210d4 to your computer and use it in GitHub Desktop.
Save tompetko/8169b161375cd23618280dbcd00210d4 to your computer and use it in GitHub Desktop.
Portos API - NWS4 Authentication
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
namespace NineDigit.mPOS.API.Client.Examples
{
internal class ComputeSignatureResult
{
public string Scheme { get; internal set; }
public string PublicApiKey { get; internal set; }
public string Signature { get; internal set; }
public string SignedHeaderNames { get; internal set; }
public string Nonce { get; internal set; }
public string Timestamp { get; internal set; }
}
public static class IEnumerableExtensions
{
public static IReadOnlyDictionary<string, string> ToReadOnlyDictionary(this IEnumerable<KeyValuePair<string, string>> self)
{
var result = self
.ToDictionary(i => i.Key, i => i.Value)
.ToReadOnlyDictionary();
return result;
}
public static IReadOnlyDictionary<string, string> ToReadOnlyDictionary(this IDictionary<string, string> self)
{
var result = new ReadOnlyDictionary<string, string>(self);
return result;
}
}
public static class HttpHeadersExtensions
{
/// <summary>
/// Converts HTTP headers to dictionary with ordinal, ignore-case string key comparision.
/// </summary>
/// <param name="self"></param>
/// <returns></returns>
public static IDictionary<string, string> ToDictionary(this HttpHeaders self)
{
var headers = self
.Select(i => CanonicalizeHttpHeader(i))
.ToDictionary(i => i.Key, i => i.Value, StringComparer.OrdinalIgnoreCase);
return headers;
}
public static IReadOnlyDictionary<string, string> ToReadOnlyDictionary(this HttpHeaders self)
{
var headers = self.ToDictionary();
var result = headers.ToReadOnlyDictionary();
return result;
}
private static KeyValuePair<string, string> CanonicalizeHttpHeader(KeyValuePair<string, IEnumerable<string>> httpHeader)
{
var value = CanonicalizeHttpHeaderValue(httpHeader.Value);
var result = new KeyValuePair<string, string>(httpHeader.Key, value);
return result;
}
private static string CanonicalizeHttpHeaderValue(IEnumerable<string> httpHeaderValue)
{
var result = string.Join(",", httpHeaderValue);
return result;
}
}
public static class ByteArrayExtensions
{
/// <summary>
/// Helper to format a byte array into string
/// </summary>
/// <param name="data">The data blob to process</param>
/// <param name="lowercase">If true, returns hex digits in lower case form</param>
/// <returns>String version of the data</returns>
public static string ToHexString(this byte[] data, bool lowercase)
{
StringBuilder sb = new StringBuilder();
string result;
for (var i = 0; i < data.Length; i++)
{
sb.Append(data[i].ToString(lowercase ? "x2" : "X2"));
}
result = sb.ToString();
return result;
}
}
internal static class AuthenticationHeaderContextSerializer
{
private const string AuthStringFormat = "{0} {1}";
private const string AuthParamStringFormat = "Credential={0},SignedHeaders={1},Timestamp={2},Signature={3}";
public static AuthenticationHeaderContext Deserialize(string content)
{
var authParts = ParseFormat(AuthStringFormat, content);
if (authParts.Length != 2)
throw new FormatException(nameof(content));
var authParamParts = ParseFormat(
AuthParamStringFormat, HttpUtility.UrlDecode(authParts[1]));
if (authParamParts.Length != 4)
throw new FormatException(nameof(content));
var timestamp = HttpUtility.UrlDecode(authParamParts[2]);
var signedHeaders = HttpUtility.UrlDecode(authParamParts[1]);
var data = new AuthenticationHeaderContext()
{
Scheme = authParts[0],
Credential = authParamParts[0],
SignedHeaders = signedHeaders,
Timestamp = timestamp,
Signature = authParamParts[3],
};
return data;
}
public static string Serialize(AuthenticationHeaderContext data)
{
if (data == null)
throw new ArgumentNullException(nameof(data));
var timestamp = HttpUtility.UrlEncode(data.Timestamp);
var signedHeaders = HttpUtility.UrlEncode(data.SignedHeaders);
var authorizationParam = HttpUtility.UrlEncode(
string.Format(AuthParamStringFormat,
data.Credential,
signedHeaders,
timestamp,
data.Signature));
var authorization = string.Format(AuthStringFormat, data.Scheme, authorizationParam);
return authorization;
}
private static string[] ParseFormat(string template, string str)
{
string pattern = "^" + Regex.Replace(template, @"\{[0-9]+\}", "(.*?)") + "$";
Regex r = new Regex(pattern);
Match m = r.Match(str);
List<string> ret = new List<string>();
for (int i = 1; i < m.Groups.Count; i++)
ret.Add(m.Groups[i].Value);
return ret.ToArray();
}
}
/// <summary>
/// Various Http helper routines
/// </summary>
public static class HttpHelper
{
/// <summary>
/// Helper routine to url encode canonicalized header names and values for safe
/// inclusion in the presigned url.
/// </summary>
/// <param name="data">The string to encode</param>
/// <param name="isPath">Whether the string is a URL path or not</param>
/// <returns>The encoded string</returns>
public static string UrlEncode(string data, bool isPath = false)
{
// The Set of accepted and valid Url characters per RFC3986. Characters outside of this set will be encoded.
const string validUrlCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~";
var encoded = new StringBuilder(data.Length * 2);
string unreservedChars = String.Concat(validUrlCharacters, (isPath ? "/:" : ""));
foreach (char symbol in Encoding.UTF8.GetBytes(data))
{
if (unreservedChars.IndexOf(symbol) != -1)
encoded.Append(symbol);
else
encoded.Append("%").Append(String.Format("{0:X2}", (int)symbol));
}
return encoded.ToString();
}
}
public sealed class AuthenticationHeaderContext
{
public AuthenticationHeaderContext()
{ }
public string Scheme { get; internal set; }
public string Credential { get; internal set; }
public string Signature { get; internal set; }
public string SignedHeaders { get; internal set; }
/// <summary>
/// ISO format date time stamp
/// </summary>
public string Timestamp { get; internal set; }
public DateTime GetUtcDateTime()
{
return SignerForAuthorizationHeader.ParseUtcDateTime(this.Timestamp);
}
public override string ToString()
{
return AuthenticationHeaderContextSerializer.Serialize(this);
}
public static AuthenticationHeaderContext Parse(string authHeader)
{
return AuthenticationHeaderContextSerializer.Deserialize(authHeader);
}
}
public abstract class SignerBase
{
protected const string SCHEME = "NWS4";
protected const string ALGORITHM = "HMAC-SHA256";
// SHA256 hash of an empty request body
protected const string EmptyBodySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
// the name of the keyed hash algorithm used in signing
protected const string HMACSHA256 = "HMACSHA256";
// algorithm used to hash the canonical request that is supplied to
// the signature computation
protected static readonly HashAlgorithm CanonicalRequestHashAlgorithm = HashAlgorithm.Create("SHA-256");
// format strings for the date/time and date stamps required during signing
protected const string ISO8601Format = "yyyy-MM-ddTHH:mm:ss.fffffffK";
// format strings for the date/time and date stamps required during signing
protected const string DateFormat = "yyyy-MM-dd";
// request canonicalization requires multiple whitespace compression
private static readonly Regex CompressWhitespaceRegex = new Regex("\\s+");
protected static string FormatDateTime(DateTime dateTimeStamp)
{
return dateTimeStamp.ToString(ISO8601Format, CultureInfo.InvariantCulture);
}
protected static string FormatDate(DateTime dateTimeStamp)
{
return dateTimeStamp.ToString(DateFormat, CultureInfo.InvariantCulture);
}
protected static DateTime ParseDateTime(string dateTime)
{
return DateTime.ParseExact(dateTime, ISO8601Format, CultureInfo.InvariantCulture);
}
public static string GetSchemeName()
{
return $"{SCHEME}-{ALGORITHM}";
}
/// <summary>
/// Returns the canonical collection of header names that will be included in
/// the signature. For NWS4, all header names must be included in the process
/// in sorted canonicalized order.
/// </summary>
/// <param name="headers">
/// The set of header names and values that will be sent with the request
/// </param>
/// <returns>
/// The set of header names canonicalized to a flattened, ;-delimited string
/// </returns>
protected static string CanonicalizeHeaderNames(IReadOnlyDictionary<string, string> headers)
{
var headersToSign = GetHeaderNamesToSign(headers);
var result = CanonicalizeHeaderNames(headersToSign);
return result;
}
protected static string[] GetHeaderNamesToSign(IReadOnlyDictionary<string, string> headers)
{
if (headers == null)
throw new ArgumentNullException(nameof(headers));
var headersToSign = new List<string>(headers.Keys);
headersToSign.Sort(StringComparer.OrdinalIgnoreCase);
var result = headersToSign.ToArray();
return result;
}
/// <summary>
/// Returns the canonical collection of header names that will be included in
/// the signature. For NWS1, all header names must be included in the process
/// in sorted canonicalized order.
/// </summary>
/// <param name="headers">
/// The set of header names and values that will be sent with the request
/// </param>
/// <returns>
/// The set of header names canonicalized to a flattened, ;-delimited string
/// </returns>
protected static string CanonicalizeHeaderNames(string[] headerNames)
{
if (headerNames == null)
throw new ArgumentNullException(nameof(headerNames));
var names = headerNames.ToList();
names.Sort(StringComparer.OrdinalIgnoreCase);
var sb = new StringBuilder();
foreach (var header in names)
{
if (sb.Length > 0)
sb.Append(";");
sb.Append(header.ToLower());
}
return sb.ToString();
}
protected static string[] ParseHeaderNames(string canonicalizedHeaderNames)
{
if (canonicalizedHeaderNames == null)
throw new ArgumentNullException(nameof(canonicalizedHeaderNames));
return canonicalizedHeaderNames.Length == 0 ?
Array.Empty<string>() :
canonicalizedHeaderNames.Split(';');
}
protected static string CanonicalizeQueryParameters(string queryParameters)
{
var canonicalizedQueryParameters = string.Empty;
if (!string.IsNullOrEmpty(queryParameters))
{
var paramDictionary = queryParameters.Split('&').Select(p => p.Split('='))
.ToDictionary(nameval => nameval[0],
nameval => nameval.Length > 1
? nameval[1] : "");
canonicalizedQueryParameters = CanonicalizeQueryParameters(paramDictionary);
}
return canonicalizedQueryParameters;
}
protected static string CanonicalizeQueryParameters(IReadOnlyDictionary<string, string> queryParameters)
{
if (queryParameters == null)
throw new ArgumentNullException(nameof(queryParameters));
var sb = new StringBuilder();
var paramKeys = new List<string>(queryParameters.Keys);
paramKeys.Sort(StringComparer.Ordinal);
foreach (var p in paramKeys)
{
if (sb.Length > 0)
sb.Append("&");
sb.AppendFormat("{0}={1}", p, queryParameters[p]);
}
var canonicalizedQueryParameters = sb.ToString();
return canonicalizedQueryParameters;
}
/// <summary>
/// Computes the canonical headers with values for the request.
/// For NWS4, all headers must be included in the signing process.
/// </summary>
/// <param name="headers">The set of headers to be encoded</param>
/// <returns>Canonicalized string of headers with values</returns>
public static string CanonicalizeHeaders(IReadOnlyDictionary<string, string> headers)
{
if (headers == null || headers.Count == 0)
return string.Empty;
// step1: sort the headers into lower-case format; we create a new
// map to ensure we can do a subsequent key lookup using a lower-case
// key regardless of how 'headers' was created.
var sortedHeaderMap = new SortedDictionary<string, string>();
foreach (var header in headers.Keys)
{
sortedHeaderMap.Add(header.ToLower(), headers[header]);
}
// step2: form the canonical header:value entries in sorted order.
// Multiple white spaces in the values should be compressed to a single
// space.
var sb = new StringBuilder();
foreach (var header in sortedHeaderMap.Keys)
{
var headerValue = CompressWhitespaceRegex.Replace(sortedHeaderMap[header], " ");
sb.AppendFormat("{0}:{1}\n", header, headerValue.Trim());
}
return sb.ToString();
}
/// <summary>
/// Returns the canonical request string to go into the signer process; this
/// consists of several canonical sub-parts.
/// </summary>
/// <param name="endpointUri"></param>
/// <param name="httpMethod"></param>
/// <param name="queryParameters"></param>
/// <param name="canonicalizedHeaderNames">
/// The set of header names to be included in the signature, formatted as a flattened, ;-delimited string
/// </param>
/// <param name="canonicalizedHeaders">
/// </param>
/// <param name="bodyHash">
/// Precomputed SHA256 hash of the request body content. For chunked encoding this
/// should be the fixed string ''.
/// </param>
/// <returns>String representing the canonicalized request for signing</returns>
public static string CanonicalizeRequest(Uri endpointUri,
string httpMethod,
string queryParameters,
string canonicalizedHeaderNames,
string canonicalizedHeaders,
string bodyHash)
{
var canonicalRequest = new StringBuilder();
canonicalRequest.AppendFormat("{0}\n", httpMethod);
canonicalRequest.AppendFormat("{0}\n", CanonicalResourcePath(endpointUri));
canonicalRequest.AppendFormat("{0}\n", queryParameters);
canonicalRequest.AppendFormat("{0}\n", canonicalizedHeaders);
canonicalRequest.AppendFormat("{0}\n", canonicalizedHeaderNames);
canonicalRequest.Append(bodyHash);
return canonicalRequest.ToString();
}
/// <summary>
/// Returns the canonicalized resource path for the service endpoint
/// </summary>
/// <param name="endpointUri">Endpoint to the service/resource</param>
/// <returns>Canonicalized resource path for the endpoint</returns>
private static string CanonicalResourcePath(Uri endpointUri)
{
if (string.IsNullOrEmpty(endpointUri.AbsolutePath))
return "/";
// encode the path per RFC3986
//HttpUtility.UrlEncode(endpointUri.AbsolutePath, )
return HttpHelper.UrlEncode(endpointUri.AbsolutePath, true);
}
/// <summary>
/// Compute and return the multi-stage signing key for the request.
/// </summary>
/// <param name="algorithm">Hashing algorithm to use</param>
/// <param name="secretAccessKey">The clear-text NWS secret key</param>
/// <param name="date">Date of the request, in ISO format</param>
/// <returns>Computed signing key</returns>
protected static byte[] DeriveSigningKey(string algorithm, string secretAccessKey, string date)
{
const string ksecretPrefix = SCHEME;
char[] ksecret = null;
byte[] result;
ksecret = (ksecretPrefix + secretAccessKey).ToCharArray();
result = ComputeKeyedHash(algorithm, Encoding.UTF8.GetBytes(ksecret), Encoding.UTF8.GetBytes(date));
return result;
}
/// <summary>
/// Compute and return the hash of a data blob using the specified algorithm
/// and key
/// </summary>
/// <param name="algorithm">Algorithm to use for hashing</param>
/// <param name="key">Hash key</param>
/// <param name="data">Data blob</param>
/// <returns>Hash of the data</returns>
private static byte[] ComputeKeyedHash(string algorithm, byte[] key, byte[] data)
{
var kha = KeyedHashAlgorithm.Create(algorithm);
kha.Key = key;
return kha.ComputeHash(data);
}
protected static string ComputeBodyHash(byte[] content)
{
var bodyHash = EmptyBodySHA256;
var isContentEmpty = content == null || content.Length == 0;
if (!isContentEmpty)
{
// precompute hash of the body content
var contentHash = CanonicalRequestHashAlgorithm.ComputeHash(content);
bodyHash = contentHash.ToHexString(true);
}
return bodyHash;
}
protected static IDictionary<string, string> GetHeaders(HttpHeaders headers, string[] headerNames)
{
if (headers == null)
throw new ArgumentNullException(nameof(headers));
var httpHeaders = headers.ToDictionary();
var result = GetHeaders(httpHeaders, headerNames);
return result;
}
protected static IDictionary<string, string> GetHeaders(IDictionary<string, string> headers, string[] headerNames)
{
if (headers == null)
throw new ArgumentNullException(nameof(headers));
if (headerNames == null)
throw new ArgumentNullException(nameof(headerNames));
headerNames = headerNames.Distinct().ToArray();
var result = new Dictionary<string, string>();
for (var i = 0; i < headerNames.Length; i++)
{
var headerName = headerNames[i];
var header = headers.FirstOrDefault(h => string.Compare(h.Key, headerName, ignoreCase: true) == 0);
if (header.Equals(new KeyValuePair<string, string>()))
{
throw new InvalidOperationException($"Header key '{headerName}' does not exist.");
}
result.Add(headerName, header.Value);
}
return result;
}
}
public class SignerForAuthorizationHeader : SignerBase
{
const string X_ND_HEADER_KEY_NAME = "x-nd-date";
const string X_ND_CONTENT_SHA256_KEY_NAME = "x-nd-content-sha256";
public async Task SignRequestAsync(
HttpRequestMessage request,
string accessKey,
string privateKey)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
DateTime dateTime = DateTime.UtcNow;
String dateTimeStamp = FormatDateTime(dateTime);
HttpContent content = request.Content;
byte[] body = content != null ?
await content.ReadAsByteArrayAsync() : Array.Empty<byte>();
String bodyHash = ComputeBodyHash(body);
// update the headers with required 'x-nd-content-sha256', 'x-nd-date' and 'host' values
request.Headers.TryAddWithoutValidation(X_ND_HEADER_KEY_NAME, dateTimeStamp);
request.Headers.TryAddWithoutValidation(X_ND_CONTENT_SHA256_KEY_NAME, bodyHash);
if (string.IsNullOrEmpty(request.Headers.Host))
{
var hostHeader = request.RequestUri.Host;
if (!request.RequestUri.IsDefaultPort)
hostHeader += ":" + request.RequestUri.Port;
request.Headers.Add(nameof(HttpRequestHeaders.Host), hostHeader);
}
Uri requestUri = request.RequestUri;
string httpMethod = request.Method.Method;
IReadOnlyDictionary<string, string> headers = request.Headers.ToReadOnlyDictionary();
string header = GetAuthenticationHeader(
requestUri, httpMethod, headers, bodyHash, accessKey, privateKey, dateTime);
AuthenticationHeaderValue authHeader = AuthenticationHeaderValue.Parse(header);
request.Headers.Authorization = authHeader;
}
private string GetAuthenticationHeader(
Uri requestUri,
string httpMethod,
IReadOnlyDictionary<string, string> headers,
string bodyHash,
string accessKey,
string privateKey,
DateTime dateTime)
{
var result = ComputeSignatureInternal(
requestUri, httpMethod, headers,
bodyHash, accessKey, privateKey, dateTime);
var context = new AuthenticationHeaderContext()
{
Scheme = result.Scheme,
Credential = result.PublicApiKey,
Signature = result.Signature,
SignedHeaders = result.SignedHeaderNames,
Timestamp = result.Timestamp,
};
return context.ToString();
}
private ComputeSignatureResult ComputeSignatureInternal(
Uri requestUri,
string httpMethod,
IReadOnlyDictionary<string, string> headers,
string bodyHash,
string accessKey,
string privateKey,
DateTime dateTime)
{
if (requestUri == null)
throw new ArgumentNullException(nameof(requestUri));
string queryParameters = requestUri.Query;
// TODO: Validate required Headers like Host
// canonicalize the headers; we need the set of header names as well as the
// names and values to go into the signature process
string canonicalizedHeaderNames = CanonicalizeHeaderNames(headers);
string canonicalizedHeaders = CanonicalizeHeaders(headers);
// if any query string parameters have been supplied, canonicalize them
string canonicalizedQueryParameters = CanonicalizeQueryParameters(queryParameters);
// canonicalize the various components of the request
string canonicalRequest = CanonicalizeRequest(requestUri, httpMethod,
canonicalizedQueryParameters, canonicalizedHeaderNames,
canonicalizedHeaders, bodyHash);
// Compute signature
// first get the date and time for the subsequent request, and convert
// to ISO 8601 format for use in signature generation
string dateTimeStamp = FormatDateTime(dateTime);
string dateStamp = FormatDate(dateTime);
// generate a hash of the canonical request, to go into signature computation
var canonicalRequestHashBytes = CanonicalRequestHashAlgorithm
.ComputeHash(Encoding.UTF8.GetBytes(canonicalRequest));
// construct the string to be signed
var stringToSign = new StringBuilder();
stringToSign.AppendFormat("{0}-{1}\n{2}\n{3}\n{4}",
SCHEME, ALGORITHM, dateTimeStamp, accessKey, canonicalRequestHashBytes.ToHexString(true));
// compute the signing key
var kha = KeyedHashAlgorithm.Create(HMACSHA256);
kha.Key = DeriveSigningKey(HMACSHA256, privateKey, dateStamp);
// compute the NWS4 signature and return it
var signature = kha.ComputeHash(Encoding.UTF8.GetBytes(stringToSign.ToString()));
var signatureString = signature.ToHexString(true);
Debug.WriteLine("\nCanonicalRequest:\n{0}", canonicalRequest);
Debug.WriteLine("\nStringToSign:\n{0}", stringToSign);
Debug.WriteLine("\nSignature:\n{0}", signatureString);
//
var schemeName = GetSchemeName();
var result = new ComputeSignatureResult()
{
Scheme = schemeName,
PublicApiKey = accessKey,
Signature = signatureString,
SignedHeaderNames = canonicalizedHeaderNames,
Timestamp = dateTimeStamp
};
return result;
}
public bool CanValidateSignature(string authHeaderValue)
{
var canParse = AuthenticationHeaderValue.TryParse(
authHeaderValue, out AuthenticationHeaderValue header);
return canParse ?
this.CanValidateSignature(header) :
false;
}
public bool CanValidateSignature(AuthenticationHeaderValue header)
{
if (header == null)
throw new ArgumentNullException(nameof(header));
var nws2Scheme = GetSchemeName();
var scheme = header.Scheme;
return scheme == nws2Scheme;
}
public Task ValidateSignatureAsync(
HttpRequestMessage request, string privateKey)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
var authHeaderValue = request.Headers.Authorization?.ToString();
if (string.IsNullOrEmpty(authHeaderValue))
throw new ArgumentException("No authorization header.", nameof(request));
var headerContext = AuthenticationHeaderContext.Parse(authHeaderValue);
var result = this.ValidateSignatureAsync(request, headerContext, privateKey);
return result;
}
public async Task ValidateSignatureAsync(
HttpRequestMessage request, AuthenticationHeaderContext authHeaderContext, string privateKey)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (authHeaderContext == null)
throw new ArgumentNullException(nameof(authHeaderContext));
string scheme = GetSchemeName();
if (authHeaderContext.Scheme != scheme)
throw new ArgumentException("Authorization header is not of NWS2 header type.", nameof(request));
Uri requestUri = request.RequestUri;
string httpMethod = request.Method.Method;
string[] signedHeaderNames = ParseHeaderNames(authHeaderContext.SignedHeaders);
IReadOnlyDictionary<string, string> signedHeaders =
GetHeaders(request.Headers, signedHeaderNames).ToReadOnlyDictionary();
byte[] content = request.Content != null ?
await request.Content.ReadAsByteArrayAsync() : null;
String bodyHash = ComputeBodyHash(content);
DateTime dateTime = ParseUtcDateTime(authHeaderContext.Timestamp);
ComputeSignatureResult result = ComputeSignatureInternal(
request.RequestUri, httpMethod, signedHeaders,
bodyHash, authHeaderContext.Credential, privateKey, dateTime);
if (result.Signature != authHeaderContext.Signature)
throw new InvalidOperationException("Invalid signature.");
}
internal static DateTime ParseUtcDateTime(string dateTimeStamp)
{
return ParseDateTime(dateTimeStamp).ToUniversalTime();
}
}
class Program
{
static void Main(string[] args)
{
var publicApiKey = "61d6b0acdcf45ea5cf0fcf9c1ce3e82fb8c06b91";
var privateApiKey = "2b643d8aace5a6ba16669d36778df2ad540f7ac9616a1f3d4b6a6ffbf90b7c39";
var url = "http://localhost:3000/api/stocks";
var request = new HttpRequestMessage(HttpMethod.Get, url);
var nws4Signer = new SignerForAuthorizationHeader();
nws4Signer.SignRequestAsync(request, publicApiKey, privateApiKey).Wait();
var httpClient = new HttpClient();
var response = httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead).Result;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment