Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ internal static IServiceCollection AddGoaService(this IServiceCollection service

services.TryAddSingleton<ICredentialProviderChain, CredentialProviderChain>();

// Pre-signed URL generation (query-string SigV4). Reuses the same credential chain.
services.TryAddSingleton<IRequestPresigner, RequestPresigner>();

return services;
}

Expand Down
74 changes: 74 additions & 0 deletions src/Clients/Goa.Clients.Core/Http/IRequestPresigner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using ErrorOr;

namespace Goa.Clients.Core.Http;

/// <summary>
/// Generates AWS SigV4 pre-signed URLs (query-string signing) for AWS requests.
/// A pre-signed URL grants time-limited access to a single operation without the caller
/// needing AWS credentials, which lets bytes flow directly to/from the service instead of
/// being proxied through the signing application.
/// </summary>
/// <remarks>
/// The canonical URI is signed using single URI-encoding (S3 style): the path of
/// <see cref="PresignParameters.Uri"/> is signed as-is. This is correct for S3, the current
/// consumer. Services that require the canonical path to be double-encoded are not yet supported.
/// </remarks>
public interface IRequestPresigner
{
/// <summary>
/// Produces a pre-signed URL for the operation described by <paramref name="parameters"/>.
/// The returned URL embeds the SigV4 authentication as query-string parameters
/// (<c>X-Amz-Algorithm</c>, <c>X-Amz-Credential</c>, <c>X-Amz-Date</c>, <c>X-Amz-Expires</c>,
/// <c>X-Amz-SignedHeaders</c>, an optional <c>X-Amz-Security-Token</c>, and <c>X-Amz-Signature</c>),
/// using the <c>UNSIGNED-PAYLOAD</c> content hash.
/// </summary>
/// <param name="parameters">The request to pre-sign.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The fully-qualified pre-signed URL, or an error if credentials could not be resolved.</returns>
ValueTask<ErrorOr<string>> PresignAsync(PresignParameters parameters, CancellationToken cancellationToken = default);
}

/// <summary>
/// Describes a single AWS operation to be pre-signed.
/// </summary>
public sealed class PresignParameters
{
/// <summary>
/// The HTTP method the caller will use against the pre-signed URL (e.g. GET, PUT).
/// </summary>
public required HttpMethod Method { get; init; }

/// <summary>
/// The scheme, host and (already-encoded) path of the target resource. Any query string on this
/// URI is ignored; supply signed query parameters via <see cref="QueryParameters"/> instead.
/// </summary>
public required Uri Uri { get; init; }

/// <summary>
/// The AWS region used for the SigV4 credential scope (e.g. "us-east-1").
/// </summary>
public required string Region { get; init; }

/// <summary>
/// The AWS service name used for SigV4 signing (e.g. "s3").
/// </summary>
public required string Service { get; init; }

/// <summary>
/// How long the generated URL remains valid. Clamped to the SigV4 maximum of 7 days.
/// </summary>
public required TimeSpan Expiry { get; init; }

/// <summary>
/// Additional headers, beyond the mandatory <c>host</c>, that the caller commits to sending when
/// using the URL (e.g. <c>content-length</c> to bind an exact upload size). Header names are
/// treated case-insensitively; the caller MUST send matching values or the signature will fail.
/// </summary>
public IReadOnlyList<KeyValuePair<string, string>>? SignedHeaders { get; init; }

/// <summary>
/// Additional query-string parameters to include in, and sign as part of, the URL
/// (e.g. <c>response-content-disposition</c> for a download). Values are supplied unencoded.
/// </summary>
public IReadOnlyList<KeyValuePair<string, string>>? QueryParameters { get; init; }
}
235 changes: 235 additions & 0 deletions src/Clients/Goa.Clients.Core/Http/RequestPresigner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
using ErrorOr;
using Goa.Clients.Core.Credentials;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

namespace Goa.Clients.Core.Http;

/// <summary>
/// Default <see cref="IRequestPresigner"/> implementation producing AWS SigV4 query-string
/// pre-signed URLs. Uses the <c>UNSIGNED-PAYLOAD</c> content hash, which is the standard for
/// pre-signed URLs where the body is transferred directly between the caller and the service.
/// </summary>
internal sealed class RequestPresigner : IRequestPresigner
{
private const string Algorithm = "AWS4-HMAC-SHA256";
private const string UnsignedPayload = "UNSIGNED-PAYLOAD";
private const string CredentialSuffix = "aws4_request";

// SigV4 caps X-Amz-Expires at 7 days.
private static readonly TimeSpan MaxExpiry = TimeSpan.FromDays(7);

private readonly ICredentialProviderChain _credentialProvider;
private readonly TimeProvider _timeProvider;

/// <summary>
/// Initializes a new instance of the <see cref="RequestPresigner"/> class.
/// </summary>
/// <param name="credentialProvider">The credential chain used to sign URLs.</param>
/// <param name="timeProvider">
/// Time source for the signing timestamp. Defaults to <see cref="TimeProvider.System"/>;
/// injectable so signatures can be verified deterministically in tests.
/// </param>
public RequestPresigner(ICredentialProviderChain credentialProvider, TimeProvider? timeProvider = null)
{
_credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
}

/// <inheritdoc />
public async ValueTask<ErrorOr<string>> PresignAsync(PresignParameters parameters, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(parameters);
ArgumentNullException.ThrowIfNull(parameters.Uri);
ArgumentException.ThrowIfNullOrWhiteSpace(parameters.Region);
ArgumentException.ThrowIfNullOrWhiteSpace(parameters.Service);

// Reserved-name guards: the X-Amz-* query params and the host header are produced and signed
// by this method. Letting a caller also supply them would emit duplicates and break the URL.
if (parameters.QueryParameters is { Count: > 0 })
{
foreach (var param in parameters.QueryParameters)
{
if (param.Key.StartsWith("X-Amz-", StringComparison.OrdinalIgnoreCase))
return Error.Validation("Presign.ReservedQueryParameter", $"Query parameter '{param.Key}' is reserved for SigV4 signing and cannot be supplied by the caller.");
}
}
if (parameters.SignedHeaders is { Count: > 0 })
{
foreach (var header in parameters.SignedHeaders)
{
if (string.Equals(header.Key, "host", StringComparison.OrdinalIgnoreCase))
return Error.Validation("Presign.ReservedHeader", "The 'host' header is always signed and cannot be supplied by the caller.");
}
}

var credentialsResult = await _credentialProvider.GetCredentialsAsync().ConfigureAwait(false);
if (credentialsResult.IsError)
return credentialsResult.Errors;

var credentials = credentialsResult.Value;

var now = _timeProvider.GetUtcNow().UtcDateTime;
var amzDate = now.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
var dateStamp = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);

var expirySeconds = (long)Math.Clamp(parameters.Expiry.TotalSeconds, 1, MaxExpiry.TotalSeconds);

var uri = parameters.Uri;
var canonicalUri = uri.AbsolutePath.Length == 0 ? "/" : uri.AbsolutePath;

// ---- Canonical headers (always host, plus any caller-committed headers) ----
var headers = new List<KeyValuePair<string, string>>(1 + (parameters.SignedHeaders?.Count ?? 0))
{
new("host", FormatHost(uri))
};
if (parameters.SignedHeaders is { Count: > 0 })
{
foreach (var header in parameters.SignedHeaders)
headers.Add(new KeyValuePair<string, string>(header.Key.ToLowerInvariant(), NormalizeHeaderValue(header.Value)));
}
headers.Sort(static (a, b) => string.CompareOrdinal(a.Key, b.Key));

var signedHeaderNames = string.Join(';', headers.Select(static h => h.Key));

var canonicalHeaders = new StringBuilder();
foreach (var header in headers)
canonicalHeaders.Append(header.Key).Append(':').Append(header.Value).Append('\n');

// ---- Canonical query (all X-Amz-* auth params plus caller query params, sorted by encoded name) ----
var scope = $"{dateStamp}/{parameters.Region}/{parameters.Service}/{CredentialSuffix}";

var query = new List<KeyValuePair<string, string>>(6 + (parameters.QueryParameters?.Count ?? 0))
{
new("X-Amz-Algorithm", Algorithm),
new("X-Amz-Credential", $"{credentials.AccessKeyId}/{scope}"),
new("X-Amz-Date", amzDate),
new("X-Amz-Expires", expirySeconds.ToString(CultureInfo.InvariantCulture)),
new("X-Amz-SignedHeaders", signedHeaderNames)
};
if (!string.IsNullOrWhiteSpace(credentials.SessionToken))
query.Add(new KeyValuePair<string, string>("X-Amz-Security-Token", credentials.SessionToken!));
if (parameters.QueryParameters is { Count: > 0 })
{
foreach (var param in parameters.QueryParameters)
query.Add(new KeyValuePair<string, string>(param.Key, param.Value ?? string.Empty));
}

var canonicalQuery = BuildCanonicalQuery(query);

// ---- Canonical request -> string to sign -> signature ----
var canonicalRequest = new StringBuilder()
.Append(parameters.Method.Method).Append('\n')
.Append(canonicalUri).Append('\n')
.Append(canonicalQuery).Append('\n')
.Append(canonicalHeaders).Append('\n')
.Append(signedHeaderNames).Append('\n')
.Append(UnsignedPayload)
.ToString();

var canonicalRequestHash = ToHexLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonicalRequest)));

var stringToSign = $"{Algorithm}\n{amzDate}\n{scope}\n{canonicalRequestHash}";

var signingKey = DeriveSigningKey(credentials.SecretAccessKey, dateStamp, parameters.Region, parameters.Service);
var signature = ToHexLower(HmacSha256(signingKey, Encoding.UTF8.GetBytes(stringToSign)));

// Reuse the already-encoded canonical query for the URL, then append the signature.
var url = $"{uri.GetLeftPart(UriPartial.Path)}?{canonicalQuery}&X-Amz-Signature={signature}";
return url;
}

/// <summary>
/// Canonicalizes a header value per SigV4: trim outer whitespace and collapse each run of
/// internal whitespace to a single space, matching the normalization S3 applies before it
/// recomputes the signature.
/// </summary>
private static string NormalizeHeaderValue(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;

var trimmed = value.AsSpan().Trim();
var builder = new StringBuilder(trimmed.Length);
var pendingSpace = false;
foreach (var ch in trimmed)
{
if (ch is ' ' or '\t')
{
pendingSpace = true;
continue;
}

if (pendingSpace && builder.Length > 0)
builder.Append(' ');
pendingSpace = false;
builder.Append(ch);
}
return builder.ToString();
}

private static string FormatHost(Uri uri)
{
var host = uri.IdnHost;
return uri.IsDefaultPort || uri.Port <= 0 ? host : $"{host}:{uri.Port}";
}

private static string BuildCanonicalQuery(List<KeyValuePair<string, string>> query)
{
var encoded = new List<KeyValuePair<string, string>>(query.Count);
foreach (var param in query)
encoded.Add(new KeyValuePair<string, string>(Rfc3986Encode(param.Key), Rfc3986Encode(param.Value)));

encoded.Sort(static (a, b) =>
{
var byName = string.CompareOrdinal(a.Key, b.Key);
return byName != 0 ? byName : string.CompareOrdinal(a.Value, b.Value);
});

var builder = new StringBuilder();
for (var i = 0; i < encoded.Count; i++)
{
if (i != 0) builder.Append('&');
builder.Append(encoded[i].Key).Append('=').Append(encoded[i].Value);
}
return builder.ToString();
}

private static byte[] DeriveSigningKey(string secretAccessKey, string dateStamp, string region, string service)
{
var kDate = HmacSha256(Encoding.UTF8.GetBytes("AWS4" + secretAccessKey), Encoding.UTF8.GetBytes(dateStamp));
var kRegion = HmacSha256(kDate, Encoding.UTF8.GetBytes(region));
var kService = HmacSha256(kRegion, Encoding.UTF8.GetBytes(service));
return HmacSha256(kService, Encoding.UTF8.GetBytes(CredentialSuffix));
}

private static byte[] HmacSha256(byte[] key, byte[] data) => HMACSHA256.HashData(key, data);

private static string ToHexLower(byte[] bytes) => Convert.ToHexStringLower(bytes);

/// <summary>
/// RFC 3986 percent-encoding over the UTF-8 bytes of <paramref name="value"/>, leaving only the
/// unreserved set (<c>A-Z a-z 0-9 - _ . ~</c>) unescaped. This is the encoding AWS SigV4 requires
/// for canonical query-string names and values (note: space encodes to <c>%20</c>, not <c>+</c>).
/// </summary>
private static string Rfc3986Encode(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var builder = new StringBuilder(bytes.Length);
foreach (var b in bytes)
{
if (IsUnreserved(b))
builder.Append((char)b);
else
builder.Append('%').Append(HexUpper[b >> 4]).Append(HexUpper[b & 0xF]);
}
return builder.ToString();
}

private const string HexUpper = "0123456789ABCDEF";

private static bool IsUnreserved(byte b) =>
b is (>= (byte)'A' and <= (byte)'Z') or (>= (byte)'a' and <= (byte)'z') or (>= (byte)'0' and <= (byte)'9')
or (byte)'-' or (byte)'_' or (byte)'.' or (byte)'~';
}
20 changes: 20 additions & 0 deletions src/Clients/Goa.Clients.S3/IS3Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using Goa.Clients.S3.Operations.DeleteObject;
using Goa.Clients.S3.Operations.GetObject;
using Goa.Clients.S3.Operations.HeadObject;
using Goa.Clients.S3.Operations.PresignGetObject;
using Goa.Clients.S3.Operations.PresignPutObject;
using Goa.Clients.S3.Operations.PutObject;

namespace Goa.Clients.S3;
Expand Down Expand Up @@ -48,4 +50,22 @@ public interface IS3Client
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The delete object response, or an error if the operation failed.</returns>
Task<ErrorOr<DeleteObjectResponse>> DeleteObjectAsync(DeleteObjectRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Generates a time-limited pre-signed URL that grants GET access to an object without AWS
/// credentials, letting the object bytes be downloaded directly from S3.
/// </summary>
/// <param name="request">The pre-sign GET request.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The pre-signed URL, or an error if the request was invalid or credentials could not be resolved.</returns>
Task<ErrorOr<string>> PresignGetObjectAsync(PresignGetObjectRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Generates a time-limited pre-signed URL that grants PUT access to an object without AWS
/// credentials, letting the object bytes be uploaded directly to S3.
/// </summary>
/// <param name="request">The pre-sign PUT request.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The pre-signed URL, or an error if the request was invalid or credentials could not be resolved.</returns>
Task<ErrorOr<string>> PresignPutObjectAsync(PresignPutObjectRequest request, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace Goa.Clients.S3.Operations.PresignGetObject;

/// <summary>
/// Request to generate a pre-signed URL for downloading an object with a GET request.
/// </summary>
public sealed class PresignGetObjectRequest
{
/// <summary>
/// The name of the bucket containing the object.
/// </summary>
public required string Bucket { get; init; }

/// <summary>
/// The key of the object to download.
/// </summary>
public required string Key { get; init; }

/// <summary>
/// How long the generated URL remains valid. Defaults to 5 minutes; clamped to the SigV4 maximum of 7 days.
/// </summary>
public TimeSpan Expiry { get; init; } = TimeSpan.FromMinutes(5);

/// <summary>
/// When set, adds a signed <c>response-content-type</c> override so S3 returns this
/// <c>Content-Type</c> header on the download.
/// </summary>
public string? ResponseContentType { get; init; }

/// <summary>
/// When set, adds a signed <c>response-content-disposition</c> override so S3 returns this
/// <c>Content-Disposition</c> header on the download (e.g. to force a filename).
/// </summary>
public string? ResponseContentDisposition { get; init; }
}
Loading
Loading