• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

loresoft / HashGate / 29697765278

19 Jul 2026 05:55PM UTC coverage: 65.982% (+2.8%) from 63.149%
29697765278

push

github

pwelter34
Add AddHmacClient and runtime options updates

235 of 472 branches covered (49.79%)

Branch coverage included in aggregate %.

146 of 173 new or added lines in 11 files covered. (84.39%)

1 existing line in 1 file now uncovered.

696 of 939 relevant lines covered (74.12%)

26.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.88
/src/HashGate.HttpClient/HttpRequestMessageExtensions.cs
1
using System.Net.Http;
2
using System.Security.Cryptography;
3

4
namespace HashGate.HttpClient;
5

6
/// <summary>
7
/// Provides extension methods for <see cref="HttpRequestMessage"/> to add HMAC authentication headers.
8
/// These methods enable automatic signing of HTTP requests using HMAC-SHA256 authentication.
9
/// </summary>
10
public static class HttpRequestMessageExtensions
11
{
12
    /// <summary>
13
    /// Adds HMAC authentication headers to an HTTP request message using the specified client credentials and signed headers.
14
    /// This method automatically generates the required authentication headers including timestamp, content hash, and authorization signature.
15
    /// </summary>
16
    /// <param name="request">The HTTP request message to add HMAC authentication headers to.</param>
17
    /// <param name="client">The client identifier (access key ID) used for authentication.</param>
18
    /// <param name="secret">The secret key used for HMAC-SHA256 signature generation.</param>
19
    /// <param name="signedHeaders">
20
    /// An optional list of header names to include in the signature calculation.
21
    /// If <c>null</c>, the default signed headers (host, x-timestamp, x-content-sha256) will be used.
22
    /// If provided, the default headers will be merged with the specified headers.
23
    /// </param>
24
    /// <param name="cancellationToken">A cancellation token to cancel operation.</param>
25
    /// <returns>A task that represents the asynchronous operation of adding HMAC authentication headers.</returns>
26
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="request"/> is <c>null</c>.</exception>
27
    /// <exception cref="ArgumentException">Thrown when <paramref name="client"/> or <paramref name="secret"/> is <c>null</c>, empty, or whitespace.</exception>
28
    /// <remarks>
29
    /// <para>
30
    /// This method performs the following operations:
31
    /// </para>
32
    /// <list type="number">
33
    /// <item><description>Adds an x-timestamp header with the current Unix timestamp</description></item>
34
    /// <item><description>Computes and adds an x-content-sha256 header with the SHA256 hash of the request body</description></item>
35
    /// <item><description>Creates a canonical string representation of the request</description></item>
36
    /// <item><description>Generates an HMAC-SHA256 signature of the canonical string</description></item>
37
    /// <item><description>Adds an Authorization header with the HMAC authentication information</description></item>
38
    /// </list>
39
    /// <para>
40
    /// The method ensures that required headers are always included in the signature, even if not explicitly specified in the signedHeaders parameter.
41
    /// </para>
42
    /// </remarks>
43
    /// <example>
44
    /// <code>
45
    /// var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data");
46
    /// await request.AddHmacAuthentication("my-client-id", "my-secret-key");
47
    ///
48
    /// // Add custom signed headers
49
    /// await request.AddHmacAuthentication(
50
    ///     "my-client-id",
51
    ///     "my-secret-key",
52
    ///     ["host", "x-timestamp", "x-content-sha256", "content-type"]
53
    /// );
54
    /// </code>
55
    /// </example>
56
    public static async Task AddHmacAuthentication(
57
        this HttpRequestMessage request,
58
        string client,
59
        string secret,
60
        IReadOnlyList<string>? signedHeaders = null,
61
        CancellationToken cancellationToken = default)
62
    {
63
        if (request is null)
14!
64
            throw new ArgumentNullException(nameof(request));
×
65

66
        if (client is null)
14!
67
            throw new ArgumentNullException(nameof(client));
×
68

69
        if (string.IsNullOrWhiteSpace(client))
14!
70
            throw new ArgumentException("Client cannot be empty or whitespace.", nameof(client));
×
71

72
        if (secret is null)
14!
73
            throw new ArgumentNullException(nameof(secret));
×
74

75
        if (string.IsNullOrWhiteSpace(secret))
14!
76
            throw new ArgumentException("Secret cannot be empty or whitespace.", nameof(secret));
×
77

78
        // ensure required headers are present, dedup and sort case-insensitively
79
        if (signedHeaders is null)
14!
80
        {
81
            // common case: reuse the shared default headers without allocating
82
            signedHeaders = HmacAuthenticationShared.DefaultSignedHeaders;
14✔
83
        }
84
        else
85
        {
NEW
86
            var headerSet = new SortedSet<string>(HmacAuthenticationShared.DefaultSignedHeaders, StringComparer.OrdinalIgnoreCase);
×
UNCOV
87
            headerSet.UnionWith(signedHeaders);
×
NEW
88
            signedHeaders = [.. headerSet];
×
89
        }
90

91
        // add timestamp header
92
        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
14✔
93
        request.Headers.Add(HmacAuthenticationShared.TimeStampHeaderName, timestamp.ToString());
14✔
94

95
        // compute content hash
96
        var contentHash = await GenerateContentHash(request, cancellationToken).ConfigureAwait(false);
14✔
97
        request.Headers.Add(HmacAuthenticationShared.ContentHashHeaderName, contentHash);
14✔
98

99
        // generate nonce
100
        request.Headers.Add(HmacAuthenticationShared.NonceHeaderName, Guid.NewGuid().ToString("N"));
14✔
101

102
        // get header values
103
        var headerValues = GetHeaderValues(request, signedHeaders);
14✔
104

105
        // create string to sign
106
        var stringToSign = HmacAuthenticationShared.CreateStringToSign(
14!
107
            method: request.Method.Method,
14✔
108
            pathAndQuery: request.RequestUri?.PathAndQuery ?? string.Empty,
14✔
109
            headerValues: headerValues);
14✔
110

111
        // compute signature
112
        var signature = HmacAuthenticationShared.GenerateSignature(stringToSign, secret);
14✔
113

114
        // Build Authorization header
115
        var authorizationHeader = HmacAuthenticationShared.GenerateAuthorizationHeader(
14✔
116
            client: client,
14✔
117
            signedHeaders: signedHeaders,
14✔
118
            signature: signature);
14✔
119

120
        // Add Authorization header to request
121
        request.Headers.Add(HmacAuthenticationShared.AuthorizationHeaderName, authorizationHeader);
14✔
122
    }
14✔
123

124
    /// <summary>
125
    /// Adds HMAC authentication headers to an HTTP request message using the specified authentication options.
126
    /// This is a convenience method that extracts the client credentials and signed headers from the options object.
127
    /// </summary>
128
    /// <param name="request">The HTTP request message to add HMAC authentication headers to.</param>
129
    /// <param name="options">The HMAC authentication options containing client credentials and configuration.</param>
130
    /// <param name="cancellationToken">A cancellation token to cancel operation.</param>
131
    /// <returns>A task that represents the asynchronous operation of adding HMAC authentication headers.</returns>
132
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="request"/> or <paramref name="options"/> is <c>null</c>.</exception>
133
    /// <remarks>
134
    /// This method delegates to the main <see cref="AddHmacAuthentication(HttpRequestMessage, string, string, IReadOnlyList{string}?, CancellationToken)"/>
135
    /// method using the client, secret, and signed headers from the provided options.
136
    /// </remarks>
137
    /// <example>
138
    /// <code>
139
    /// var options = new HmacAuthenticationOptions
140
    /// {
141
    ///     Client = "my-client-id",
142
    ///     Secret = "my-secret-key",
143
    ///     SignedHeaders = ["host", "x-timestamp", "x-content-sha256"]
144
    /// };
145
    ///
146
    /// var request = new HttpRequestMessage(HttpMethod.Post, "https://api.example.com/users");
147
    /// await request.AddHmacAuthentication(options);
148
    /// </code>
149
    /// </example>
150
    public static Task AddHmacAuthentication(
151
        this HttpRequestMessage request,
152
        HmacAuthenticationOptions options,
153
        CancellationToken cancellationToken = default)
154
    {
155
        if (request is null)
12!
156
            throw new ArgumentNullException(nameof(request));
×
157
        if (options is null)
12!
158
            throw new ArgumentNullException(nameof(options));
×
159

160
        return request.AddHmacAuthentication(
12✔
161
            client: options.Client,
12✔
162
            secret: options.Secret,
12✔
163
            signedHeaders: options.SignedHeaders,
12✔
164
            cancellationToken: cancellationToken);
12✔
165
    }
166

167
    /// <summary>
168
    /// Generates a Base64-encoded SHA256 hash of the HTTP request content.
169
    /// If the request has no content, returns the hash of an empty string.
170
    /// </summary>
171
    /// <param name="request">The HTTP request message to generate the content hash for.</param>
172
    /// <param name="cancellationToken">A cancellation token to cancel operation.</param>
173
    /// <returns>
174
    /// A task that represents the asynchronous operation. The task result contains a Base64-encoded SHA256 hash of the request content.
175
    /// </returns>
176
    /// <remarks>
177
    /// <para>
178
    /// This method handles the following scenarios:
179
    /// </para>
180
    /// <list type="bullet">
181
    /// <item><description>If the request has no content, returns <see cref="HmacAuthenticationShared.EmptyContentHash"/></description></item>
182
    /// <item><description>If the request has content, reads the content as bytes, computes SHA256 hash, and recreates the content stream</description></item>
183
    /// <item><description>Preserves all original content headers when recreating the content stream</description></item>
184
    /// </list>
185
    /// <para>
186
    /// The method consumes the original content stream and recreates it to ensure the request can still be sent normally.
187
    /// This is necessary because HTTP content streams can typically only be read once.
188
    /// </para>
189
    /// <para>
190
    /// Uses stack allocation for Base64 conversion when possible for improved performance.
191
    /// </para>
192
    /// </remarks>
193
    /// <example>
194
    /// <code>
195
    /// var request = new HttpRequestMessage(HttpMethod.Post, "https://api.example.com/users");
196
    /// request.Content = new StringContent("{\"name\":\"John\"}", Encoding.UTF8, "application/json");
197
    ///
198
    /// var contentHash = await request.GenerateContentHash();
199
    /// // contentHash will contain the Base64-encoded SHA256 hash of the JSON content
200
    /// </code>
201
    /// </example>
202
    public static async Task<string> GenerateContentHash(
203
        this HttpRequestMessage request,
204
        CancellationToken cancellationToken = default)
205
    {
206
        if (request.Content == null)
23✔
207
            return HmacAuthenticationShared.EmptyContentHash;
14✔
208

209
#if NETSTANDARD2_0 || NETFRAMEWORK
210
        var bodyBytes = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
211
        using var sha256 = SHA256.Create();
212
        var hashBytes = sha256.ComputeHash(bodyBytes);
213
#else
214
        var bodyBytes = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
9✔
215
        var hashBytes = SHA256.HashData(bodyBytes);
9✔
216
#endif
217

218
        // consume the content stream, need to recreate it unless it is already buffered
219
        if (request.Content is not ByteArrayContent)
9✔
220
        {
221
            var originalContent = new ByteArrayContent(bodyBytes);
3✔
222
            foreach (var header in request.Content.Headers)
16✔
223
                originalContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
5✔
224

225
            // Restore content with headers
226
            request.Content = originalContent;
3✔
227
        }
228

229
#if !NETSTANDARD2_0 && !NETFRAMEWORK
230
        // 32 bytes SHA256 -> 44 chars base64
231
        Span<char> base64 = stackalloc char[44];
9✔
232
        if (Convert.TryToBase64Chars(hashBytes, base64, out int charsWritten))
9!
233
            return new string(base64[..charsWritten]);
9✔
234
#endif
235

236
        // if stackalloc is not large enough (should not happen for SHA256)
237
        return Convert.ToBase64String(hashBytes);
×
238
    }
23✔
239

240
    /// <summary>
241
    /// Retrieves the values of the specified headers from an HTTP request message.
242
    /// Returns an array of header values in the same order as the provided header names.
243
    /// </summary>
244
    /// <param name="request">The HTTP request message to extract header values from.</param>
245
    /// <param name="signedHeaders">The list of header names whose values should be retrieved.</param>
246
    /// <returns>
247
    /// An array of header values corresponding to the signed headers.
248
    /// If a header is not found, an empty string is returned for that position.
249
    /// </returns>
250
    /// <remarks>
251
    /// This method calls <see cref="GetHeaderValue(HttpRequestMessage, string)"/> for each header name
252
    /// and collects the results into an array. The order of values matches the order of header names.
253
    /// </remarks>
254
    private static string[] GetHeaderValues(
255
        HttpRequestMessage request,
256
        IReadOnlyList<string> signedHeaders)
257
    {
258
        var headerValues = new string[signedHeaders.Count];
14✔
259

260
        for (var i = 0; i < signedHeaders.Count; i++)
140✔
261
            headerValues[i] = GetHeaderValue(request, signedHeaders[i]) ?? string.Empty;
56!
262

263
        return headerValues;
14✔
264
    }
265

266
    /// <summary>
267
    /// Retrieves the value of a specific header from an HTTP request message.
268
    /// Handles special cases for standard HTTP headers and searches both request headers and content headers.
269
    /// </summary>
270
    /// <param name="request">The HTTP request message to extract the header value from.</param>
271
    /// <param name="headerName">The name of the header to retrieve (case-insensitive).</param>
272
    /// <returns>
273
    /// The header value as a string, or <c>null</c> if the header is not found.
274
    /// For headers with multiple values, returns a comma-separated string.
275
    /// </returns>
276
    /// <remarks>
277
    /// <para>
278
    /// This method handles the following special cases:
279
    /// </para>
280
    /// <list type="bullet">
281
    /// <item><description><c>host</c> - Returns the authority part of the request URI in lowercase</description></item>
282
    /// <item><description><c>content-type</c> - Returns the content type from content headers</description></item>
283
    /// <item><description><c>content-length</c> - Returns the content length from content headers</description></item>
284
    /// <item><description><c>user-agent</c> - Returns the user agent header as a string</description></item>
285
    /// </list>
286
    /// <para>
287
    /// For other headers, searches first in request headers, then in content headers.
288
    /// Multiple header values are joined with commas as per HTTP specification.
289
    /// </para>
290
    /// </remarks>
291
    private static string? GetHeaderValue(
292
        HttpRequestMessage request,
293
        string headerName)
294
    {
295
        if (headerName.Equals("host", StringComparison.InvariantCultureIgnoreCase))
56✔
296
            return request.RequestUri?.Authority.ToLowerInvariant();
14!
297

298
        if (headerName.Equals("content-type", StringComparison.InvariantCultureIgnoreCase))
42!
299
            return request.Content?.Headers.ContentType?.ToString();
×
300

301
        if (headerName.Equals("content-length", StringComparison.InvariantCultureIgnoreCase))
42!
302
            return request.Content?.Headers.ContentLength?.ToString();
×
303

304
        if (headerName.Equals("user-agent", StringComparison.InvariantCultureIgnoreCase))
42!
305
            return request.Headers.UserAgent.ToString();
×
306

307
        if (request.Headers.TryGetValues(headerName, out var values))
42!
308
            return values != null ? string.Join(",", values) : null;
42!
309

310
        if (request.Content != null && request.Content.Headers.TryGetValues(headerName, out var contentValues))
×
311
            return contentValues != null ? string.Join(",", contentValues) : null;
×
312

313
        return null;
×
314
    }
315
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc