diff --git a/src/Clients/Goa.Clients.Core/Configuration/ServiceExtensions.cs b/src/Clients/Goa.Clients.Core/Configuration/ServiceExtensions.cs index f2ed6c3..7cafe24 100644 --- a/src/Clients/Goa.Clients.Core/Configuration/ServiceExtensions.cs +++ b/src/Clients/Goa.Clients.Core/Configuration/ServiceExtensions.cs @@ -62,6 +62,9 @@ internal static IServiceCollection AddGoaService(this IServiceCollection service services.TryAddSingleton(); + // Pre-signed URL generation (query-string SigV4). Reuses the same credential chain. + services.TryAddSingleton(); + return services; } diff --git a/src/Clients/Goa.Clients.Core/Http/IRequestPresigner.cs b/src/Clients/Goa.Clients.Core/Http/IRequestPresigner.cs new file mode 100644 index 0000000..6998fa8 --- /dev/null +++ b/src/Clients/Goa.Clients.Core/Http/IRequestPresigner.cs @@ -0,0 +1,74 @@ +using ErrorOr; + +namespace Goa.Clients.Core.Http; + +/// +/// 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. +/// +/// +/// The canonical URI is signed using single URI-encoding (S3 style): the path of +/// 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. +/// +public interface IRequestPresigner +{ + /// + /// Produces a pre-signed URL for the operation described by . + /// The returned URL embeds the SigV4 authentication as query-string parameters + /// (X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, + /// X-Amz-SignedHeaders, an optional X-Amz-Security-Token, and X-Amz-Signature), + /// using the UNSIGNED-PAYLOAD content hash. + /// + /// The request to pre-sign. + /// A cancellation token to cancel the operation. + /// The fully-qualified pre-signed URL, or an error if credentials could not be resolved. + ValueTask> PresignAsync(PresignParameters parameters, CancellationToken cancellationToken = default); +} + +/// +/// Describes a single AWS operation to be pre-signed. +/// +public sealed class PresignParameters +{ + /// + /// The HTTP method the caller will use against the pre-signed URL (e.g. GET, PUT). + /// + public required HttpMethod Method { get; init; } + + /// + /// The scheme, host and (already-encoded) path of the target resource. Any query string on this + /// URI is ignored; supply signed query parameters via instead. + /// + public required Uri Uri { get; init; } + + /// + /// The AWS region used for the SigV4 credential scope (e.g. "us-east-1"). + /// + public required string Region { get; init; } + + /// + /// The AWS service name used for SigV4 signing (e.g. "s3"). + /// + public required string Service { get; init; } + + /// + /// How long the generated URL remains valid. Clamped to the SigV4 maximum of 7 days. + /// + public required TimeSpan Expiry { get; init; } + + /// + /// Additional headers, beyond the mandatory host, that the caller commits to sending when + /// using the URL (e.g. content-length to bind an exact upload size). Header names are + /// treated case-insensitively; the caller MUST send matching values or the signature will fail. + /// + public IReadOnlyList>? SignedHeaders { get; init; } + + /// + /// Additional query-string parameters to include in, and sign as part of, the URL + /// (e.g. response-content-disposition for a download). Values are supplied unencoded. + /// + public IReadOnlyList>? QueryParameters { get; init; } +} diff --git a/src/Clients/Goa.Clients.Core/Http/RequestPresigner.cs b/src/Clients/Goa.Clients.Core/Http/RequestPresigner.cs new file mode 100644 index 0000000..18da479 --- /dev/null +++ b/src/Clients/Goa.Clients.Core/Http/RequestPresigner.cs @@ -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; + +/// +/// Default implementation producing AWS SigV4 query-string +/// pre-signed URLs. Uses the UNSIGNED-PAYLOAD content hash, which is the standard for +/// pre-signed URLs where the body is transferred directly between the caller and the service. +/// +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; + + /// + /// Initializes a new instance of the class. + /// + /// The credential chain used to sign URLs. + /// + /// Time source for the signing timestamp. Defaults to ; + /// injectable so signatures can be verified deterministically in tests. + /// + public RequestPresigner(ICredentialProviderChain credentialProvider, TimeProvider? timeProvider = null) + { + _credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + public async ValueTask> 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>(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(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>(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("X-Amz-Security-Token", credentials.SessionToken!)); + if (parameters.QueryParameters is { Count: > 0 }) + { + foreach (var param in parameters.QueryParameters) + query.Add(new KeyValuePair(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; + } + + /// + /// 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. + /// + 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> query) + { + var encoded = new List>(query.Count); + foreach (var param in query) + encoded.Add(new KeyValuePair(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); + + /// + /// RFC 3986 percent-encoding over the UTF-8 bytes of , leaving only the + /// unreserved set (A-Z a-z 0-9 - _ . ~) unescaped. This is the encoding AWS SigV4 requires + /// for canonical query-string names and values (note: space encodes to %20, not +). + /// + 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)'~'; +} diff --git a/src/Clients/Goa.Clients.S3/IS3Client.cs b/src/Clients/Goa.Clients.S3/IS3Client.cs index 53a7129..62f1d59 100644 --- a/src/Clients/Goa.Clients.S3/IS3Client.cs +++ b/src/Clients/Goa.Clients.S3/IS3Client.cs @@ -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; @@ -48,4 +50,22 @@ public interface IS3Client /// A cancellation token to cancel the operation. /// The delete object response, or an error if the operation failed. Task> DeleteObjectAsync(DeleteObjectRequest request, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + /// The pre-sign GET request. + /// A cancellation token to cancel the operation. + /// The pre-signed URL, or an error if the request was invalid or credentials could not be resolved. + Task> PresignGetObjectAsync(PresignGetObjectRequest request, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + /// The pre-sign PUT request. + /// A cancellation token to cancel the operation. + /// The pre-signed URL, or an error if the request was invalid or credentials could not be resolved. + Task> PresignPutObjectAsync(PresignPutObjectRequest request, CancellationToken cancellationToken = default); } diff --git a/src/Clients/Goa.Clients.S3/Operations/PresignGetObject/PresignGetObjectRequest.cs b/src/Clients/Goa.Clients.S3/Operations/PresignGetObject/PresignGetObjectRequest.cs new file mode 100644 index 0000000..4b92cab --- /dev/null +++ b/src/Clients/Goa.Clients.S3/Operations/PresignGetObject/PresignGetObjectRequest.cs @@ -0,0 +1,34 @@ +namespace Goa.Clients.S3.Operations.PresignGetObject; + +/// +/// Request to generate a pre-signed URL for downloading an object with a GET request. +/// +public sealed class PresignGetObjectRequest +{ + /// + /// The name of the bucket containing the object. + /// + public required string Bucket { get; init; } + + /// + /// The key of the object to download. + /// + public required string Key { get; init; } + + /// + /// How long the generated URL remains valid. Defaults to 5 minutes; clamped to the SigV4 maximum of 7 days. + /// + public TimeSpan Expiry { get; init; } = TimeSpan.FromMinutes(5); + + /// + /// When set, adds a signed response-content-type override so S3 returns this + /// Content-Type header on the download. + /// + public string? ResponseContentType { get; init; } + + /// + /// When set, adds a signed response-content-disposition override so S3 returns this + /// Content-Disposition header on the download (e.g. to force a filename). + /// + public string? ResponseContentDisposition { get; init; } +} diff --git a/src/Clients/Goa.Clients.S3/Operations/PresignPutObject/PresignPutObjectRequest.cs b/src/Clients/Goa.Clients.S3/Operations/PresignPutObject/PresignPutObjectRequest.cs new file mode 100644 index 0000000..4207db3 --- /dev/null +++ b/src/Clients/Goa.Clients.S3/Operations/PresignPutObject/PresignPutObjectRequest.cs @@ -0,0 +1,35 @@ +namespace Goa.Clients.S3.Operations.PresignPutObject; + +/// +/// Request to generate a pre-signed URL for uploading an object with a PUT request. +/// +public sealed class PresignPutObjectRequest +{ + /// + /// The name of the bucket to upload the object to. + /// + public required string Bucket { get; init; } + + /// + /// The key the object will be stored under. + /// + public required string Key { get; init; } + + /// + /// How long the generated URL remains valid. Defaults to 5 minutes; clamped to the SigV4 maximum of 7 days. + /// + public TimeSpan Expiry { get; init; } = TimeSpan.FromMinutes(5); + + /// + /// When set, signs a content-length header binding the upload to this exact byte count. + /// The caller MUST send a matching Content-Length header, and S3 rejects a body of any + /// other size, which prevents oversized uploads through the pre-signed URL. + /// + public long? ContentLength { get; init; } + + /// + /// When set, signs a content-type header. The caller MUST send a matching + /// Content-Type header when uploading. + /// + public string? ContentType { get; init; } +} diff --git a/src/Clients/Goa.Clients.S3/README.md b/src/Clients/Goa.Clients.S3/README.md index e7fc5e8..8f5a55c 100644 --- a/src/Clients/Goa.Clients.S3/README.md +++ b/src/Clients/Goa.Clients.S3/README.md @@ -17,6 +17,7 @@ dotnet add package Goa.Clients.S3 - Server-side encryption support (SSE-KMS) - User-defined object metadata - Ranged downloads +- Pre-signed URLs for direct-to-S3 upload and download (SigV4 query signing) ## Usage @@ -149,6 +150,41 @@ var result = await _s3.DeleteObjectAsync(new DeleteObjectRequest // Deleting a missing key also succeeds - S3 deletes are idempotent on unversioned buckets. ``` +### Pre-signed URLs + +Generate time-limited URLs that let a client upload or download object bytes directly to/from S3 +without AWS credentials. This keeps large payloads off your application (e.g. off the AWS Lambda +6 MB invoke limit) by transferring them straight to S3. + +```csharp +using Goa.Clients.S3.Operations.PresignGetObject; +using Goa.Clients.S3.Operations.PresignPutObject; + +// Upload: hand this URL to the client, which PUTs the bytes directly to S3. +// Binding ContentLength signs an exact size, so S3 rejects a larger body. +var uploadUrl = await _s3.PresignPutObjectAsync(new PresignPutObjectRequest +{ + Bucket = "my-bucket", + Key = "documents/report.pdf", + Expiry = TimeSpan.FromMinutes(5), + ContentLength = fileSizeBytes +}); + +// Download: the response-* overrides set the Content-Type and Content-Disposition S3 returns. +var downloadUrl = await _s3.PresignGetObjectAsync(new PresignGetObjectRequest +{ + Bucket = "my-bucket", + Key = "documents/report.pdf", + Expiry = TimeSpan.FromMinutes(2), + ResponseContentType = "application/pdf", + ResponseContentDisposition = "attachment; filename=\"report.pdf\"" +}); +``` + +Expiry is clamped to the SigV4 maximum of 7 days. The URLs carry the signer's own credentials +(including a session token when running under an IAM role), so they inherit that principal's S3 +permissions and require no bucket CORS for non-browser clients. + ### Error Handling Missing objects are surfaced as `ErrorType.NotFound` rather than exceptions: diff --git a/src/Clients/Goa.Clients.S3/S3ServiceClient.cs b/src/Clients/Goa.Clients.S3/S3ServiceClient.cs index 49ff6b9..c21a320 100644 --- a/src/Clients/Goa.Clients.S3/S3ServiceClient.cs +++ b/src/Clients/Goa.Clients.S3/S3ServiceClient.cs @@ -6,6 +6,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; using Microsoft.Extensions.Logging; using System.Net; @@ -18,14 +20,22 @@ internal sealed class S3ServiceClient : AwsServiceClient logger) : base(httpClientFactory, logger, configuration) { + _presigner = presigner ?? throw new ArgumentNullException(nameof(presigner)); } public async Task> PutObjectAsync(PutObjectRequest request, CancellationToken cancellationToken = default) @@ -180,6 +190,75 @@ public async Task> DeleteObjectAsync(DeleteObjectR } } + public Task> PresignGetObjectAsync(PresignGetObjectRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var validation = ValidateBucketAndKey(request.Bucket, request.Key); + if (validation.IsError) + return Task.FromResult>(validation.Errors); + + List>? queryParameters = null; + if (!string.IsNullOrWhiteSpace(request.ResponseContentType)) + (queryParameters ??= []).Add(new KeyValuePair("response-content-type", request.ResponseContentType)); + if (!string.IsNullOrWhiteSpace(request.ResponseContentDisposition)) + (queryParameters ??= []).Add(new KeyValuePair("response-content-disposition", request.ResponseContentDisposition)); + + return PresignAsync(HttpMethod.Get, request.Bucket, request.Key, request.Expiry, signedHeaders: null, queryParameters, cancellationToken); + } + + public Task> PresignPutObjectAsync(PresignPutObjectRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var validation = ValidateBucketAndKey(request.Bucket, request.Key); + if (validation.IsError) + return Task.FromResult>(validation.Errors); + + List>? signedHeaders = null; + if (request.ContentLength is { } contentLength) + { + if (contentLength < 0) + return Task.FromResult>(Error.Validation("S3.PresignPutObject.ContentLength", "ContentLength cannot be negative.")); + (signedHeaders ??= []).Add(new KeyValuePair("content-length", contentLength.ToString(System.Globalization.CultureInfo.InvariantCulture))); + } + if (!string.IsNullOrWhiteSpace(request.ContentType)) + (signedHeaders ??= []).Add(new KeyValuePair("content-type", request.ContentType)); + + return PresignAsync(HttpMethod.Put, request.Bucket, request.Key, request.Expiry, signedHeaders, queryParameters: null, cancellationToken); + } + + private async Task> PresignAsync( + HttpMethod method, + string bucket, + string key, + TimeSpan expiry, + IReadOnlyList>? signedHeaders, + IReadOnlyList>? queryParameters, + CancellationToken cancellationToken) + { + try + { + var parameters = new PresignParameters + { + Method = method, + Uri = BuildObjectUri(bucket, key), + Region = Configuration.Region, + Service = SigningService, + Expiry = expiry, + SignedHeaders = signedHeaders, + QueryParameters = queryParameters + }; + + return await _presigner.PresignAsync(parameters, cancellationToken); + } + catch (Exception ex) + { + Logger.RequestFailed($"Operation: Presign{method.Method}Object, Bucket: {bucket}, Key: {key}, Error: {ex.Message}"); + return Error.Failure("S3.Presign.Failed", $"Failed to pre-sign {method.Method} URL for object {key} in S3 bucket {bucket}"); + } + } + /// /// Creates an HTTP request message targeting the specified object, marked for S3-style SigV4 signing. /// diff --git a/tests/Clients/Goa.Clients.Core.Tests/Http/RequestPresignerTests.cs b/tests/Clients/Goa.Clients.Core.Tests/Http/RequestPresignerTests.cs new file mode 100644 index 0000000..032bfb7 --- /dev/null +++ b/tests/Clients/Goa.Clients.Core.Tests/Http/RequestPresignerTests.cs @@ -0,0 +1,178 @@ +using ErrorOr; +using Goa.Clients.Core.Credentials; +using Goa.Clients.Core.Http; +using Moq; + +namespace Goa.Clients.Core.Tests.Http; + +/// +/// Verifies against the AWS-documented SigV4 query-string example +/// (GET examplebucket/test.txt, credentials AKIAIOSFODNN7EXAMPLE, 20130524T000000Z) whose expected +/// signature is fixed and published, plus the session-token and extra-query-param behaviours. +/// +public class RequestPresignerTests +{ + private const string ExampleAccessKey = "AKIAIOSFODNN7EXAMPLE"; + private const string ExampleSecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + private const string ExampleSignature = "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"; + + private static readonly DateTimeOffset ExampleTime = new(2013, 5, 24, 0, 0, 0, TimeSpan.Zero); + + private static RequestPresigner CreatePresigner(AwsCredentials credentials, DateTimeOffset time) + { + var chain = new Mock(); + chain.Setup(x => x.GetCredentialsAsync()) + .Returns(new ValueTask>(credentials)); + return new RequestPresigner(chain.Object, new FixedTimeProvider(time)); + } + + [Test] + public async Task PresignAsync_MatchesAwsDocumentedExampleSignature() + { + var presigner = CreatePresigner(new AwsCredentials(ExampleAccessKey, ExampleSecretKey), ExampleTime); + + var result = await presigner.PresignAsync(new PresignParameters + { + Method = HttpMethod.Get, + Uri = new Uri("https://examplebucket.s3.amazonaws.com/test.txt"), + Region = "us-east-1", + Service = "s3", + Expiry = TimeSpan.FromSeconds(86400) + }); + + await Assert.That(result.IsError).IsFalse(); + var url = result.Value; + await Assert.That(url).Contains($"X-Amz-Signature={ExampleSignature}"); + await Assert.That(url).StartsWith("https://examplebucket.s3.amazonaws.com/test.txt?"); + await Assert.That(url).Contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"); + await Assert.That(url).Contains("X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request"); + await Assert.That(url).Contains("X-Amz-Expires=86400"); + await Assert.That(url).Contains("X-Amz-SignedHeaders=host"); + } + + [Test] + public async Task PresignAsync_WithSessionToken_IncludesEncodedSecurityTokenInQuery() + { + var presigner = CreatePresigner( + new AwsCredentials(ExampleAccessKey, ExampleSecretKey, "tok/en+with=special"), + ExampleTime); + + var result = await presigner.PresignAsync(new PresignParameters + { + Method = HttpMethod.Get, + Uri = new Uri("https://examplebucket.s3.amazonaws.com/test.txt"), + Region = "us-east-1", + Service = "s3", + Expiry = TimeSpan.FromSeconds(86400) + }); + + await Assert.That(result.IsError).IsFalse(); + // The session token is included and RFC 3986 encoded (/, +, = escaped). + await Assert.That(result.Value).Contains("X-Amz-Security-Token=tok%2Fen%2Bwith%3Dspecial"); + } + + [Test] + public async Task PresignAsync_WithResponseContentDisposition_SignsAndEncodesExtraQueryParam() + { + var presigner = CreatePresigner(new AwsCredentials(ExampleAccessKey, ExampleSecretKey), ExampleTime); + + var result = await presigner.PresignAsync(new PresignParameters + { + Method = HttpMethod.Get, + Uri = new Uri("https://examplebucket.s3.amazonaws.com/test.txt"), + Region = "us-east-1", + Service = "s3", + Expiry = TimeSpan.FromSeconds(60), + QueryParameters = [new KeyValuePair("response-content-disposition", "attachment; filename=\"a b.pdf\"")] + }); + + await Assert.That(result.IsError).IsFalse(); + await Assert.That(result.Value).Contains("response-content-disposition=attachment%3B%20filename%3D%22a%20b.pdf%22"); + } + + [Test] + public async Task PresignAsync_ClampsExpiryToSevenDays() + { + var presigner = CreatePresigner(new AwsCredentials(ExampleAccessKey, ExampleSecretKey), ExampleTime); + + var result = await presigner.PresignAsync(new PresignParameters + { + Method = HttpMethod.Put, + Uri = new Uri("https://examplebucket.s3.amazonaws.com/test.txt"), + Region = "us-east-1", + Service = "s3", + Expiry = TimeSpan.FromDays(30) + }); + + await Assert.That(result.IsError).IsFalse(); + await Assert.That(result.Value).Contains("X-Amz-Expires=604800"); + } + + [Test] + public async Task PresignAsync_CollapsesInternalWhitespaceInSignedHeaderValue() + { + // A single space and a double space in a signed header must produce the same signature, + // because S3 collapses internal whitespace before it recomputes the signature. + var single = await CreatePresigner(new AwsCredentials(ExampleAccessKey, ExampleSecretKey), ExampleTime) + .PresignAsync(HeaderParams("a b")); + var doubled = await CreatePresigner(new AwsCredentials(ExampleAccessKey, ExampleSecretKey), ExampleTime) + .PresignAsync(HeaderParams("a b")); + + await Assert.That(single.IsError).IsFalse(); + await Assert.That(doubled.IsError).IsFalse(); + await Assert.That(Signature(single.Value)).IsEqualTo(Signature(doubled.Value)); + + static PresignParameters HeaderParams(string value) => new() + { + Method = HttpMethod.Put, + Uri = new Uri("https://examplebucket.s3.amazonaws.com/test.txt"), + Region = "us-east-1", + Service = "s3", + Expiry = TimeSpan.FromSeconds(60), + SignedHeaders = [new KeyValuePair("x-custom", value)] + }; + + static string Signature(string url) => url[(url.IndexOf("X-Amz-Signature=", StringComparison.Ordinal))..]; + } + + [Test] + public async Task PresignAsync_WithCallerSuppliedReservedQueryParameter_ReturnsError() + { + var result = await CreatePresigner(new AwsCredentials(ExampleAccessKey, ExampleSecretKey), ExampleTime) + .PresignAsync(new PresignParameters + { + Method = HttpMethod.Get, + Uri = new Uri("https://examplebucket.s3.amazonaws.com/test.txt"), + Region = "us-east-1", + Service = "s3", + Expiry = TimeSpan.FromSeconds(60), + QueryParameters = [new KeyValuePair("X-Amz-Expires", "1")] + }); + + await Assert.That(result.IsError).IsTrue(); + } + + [Test] + public async Task PresignAsync_WithCallerSuppliedHostHeader_ReturnsError() + { + var result = await CreatePresigner(new AwsCredentials(ExampleAccessKey, ExampleSecretKey), ExampleTime) + .PresignAsync(new PresignParameters + { + Method = HttpMethod.Put, + Uri = new Uri("https://examplebucket.s3.amazonaws.com/test.txt"), + Region = "us-east-1", + Service = "s3", + Expiry = TimeSpan.FromSeconds(60), + SignedHeaders = [new KeyValuePair("Host", "evil.example.com")] + }); + + await Assert.That(result.IsError).IsTrue(); + } + + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTimeOffset _now; + public FixedTimeProvider(DateTimeOffset now) => _now = now; + public override DateTimeOffset GetUtcNow() => _now; + } +} diff --git a/tests/Clients/Goa.Clients.S3.Tests/S3PresignIntegrationTests.cs b/tests/Clients/Goa.Clients.S3.Tests/S3PresignIntegrationTests.cs new file mode 100644 index 0000000..716f6d7 --- /dev/null +++ b/tests/Clients/Goa.Clients.S3.Tests/S3PresignIntegrationTests.cs @@ -0,0 +1,103 @@ +using Goa.Clients.S3.Operations.PresignGetObject; +using Goa.Clients.S3.Operations.PresignPutObject; +using Goa.Clients.S3.Operations.PutObject; +using System.Text; + +namespace Goa.Clients.S3.Tests; + +/// +/// End-to-end tests for pre-signed URL generation. Each test signs a URL with the Goa client, then +/// exercises it with a bare (carrying no AWS credentials) against LocalStack, +/// proving the URL is structurally valid and usable for direct-to-S3 transfer. +/// Exact SigV4 signature correctness is asserted separately and deterministically in +/// Goa.Clients.Core.Tests.Http.RequestPresignerTests. +/// +[ClassDataSource(Shared = SharedType.PerAssembly)] +public class S3PresignIntegrationTests +{ + private readonly S3TestFixture _fixture; + + public S3PresignIntegrationTests(S3TestFixture fixture) + { + _fixture = fixture; + } + + private static string NewKey() => $"{Guid.NewGuid()}/{Guid.NewGuid()}/{Guid.NewGuid()}"; + + [Test] + public async Task PresignPut_ThenPresignGet_RoundTripsObjectBytes() + { + var key = NewKey(); + var body = Encoding.UTF8.GetBytes("pre-signed round trip payload"); + using var http = new HttpClient(); + + // Upload directly to S3 via a pre-signed PUT URL. + var putUrl = await _fixture.S3Client.PresignPutObjectAsync(new PresignPutObjectRequest + { + Bucket = _fixture.BucketName, + Key = key, + ContentLength = body.Length + }); + await Assert.That(putUrl.IsError).IsFalse(); + + using var putResponse = await http.PutAsync(putUrl.Value, new ByteArrayContent(body)); + await Assert.That(putResponse.IsSuccessStatusCode).IsTrue(); + + // Download directly from S3 via a pre-signed GET URL. + var getUrl = await _fixture.S3Client.PresignGetObjectAsync(new PresignGetObjectRequest + { + Bucket = _fixture.BucketName, + Key = key + }); + await Assert.That(getUrl.IsError).IsFalse(); + + using var getResponse = await http.GetAsync(getUrl.Value); + await Assert.That(getResponse.IsSuccessStatusCode).IsTrue(); + var downloaded = await getResponse.Content.ReadAsByteArrayAsync(); + await Assert.That(downloaded).IsEquivalentTo(body); + } + + [Test] + public async Task PresignGet_WithResponseContentDisposition_OverridesDownloadHeader() + { + var key = NewKey(); + var body = Encoding.UTF8.GetBytes("attachment payload"); + + var put = await _fixture.S3Client.PutObjectAsync(new PutObjectRequest + { + Bucket = _fixture.BucketName, + Key = key, + Body = body, + ContentType = "application/octet-stream" + }); + await Assert.That(put.IsError).IsFalse(); + + var getUrl = await _fixture.S3Client.PresignGetObjectAsync(new PresignGetObjectRequest + { + Bucket = _fixture.BucketName, + Key = key, + ResponseContentType = "application/pdf", + ResponseContentDisposition = "attachment; filename=\"passport.pdf\"" + }); + await Assert.That(getUrl.IsError).IsFalse(); + + using var http = new HttpClient(); + using var response = await http.GetAsync(getUrl.Value); + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + await Assert.That(response.Content.Headers.ContentDisposition?.ToString()) + .IsEqualTo("attachment; filename=\"passport.pdf\""); + await Assert.That(response.Content.Headers.ContentType?.ToString()).IsEqualTo("application/pdf"); + } + + [Test] + public async Task PresignPut_WithDotSegmentKey_ReturnsValidationError() + { + var result = await _fixture.S3Client.PresignPutObjectAsync(new PresignPutObjectRequest + { + Bucket = _fixture.BucketName, + Key = "../escape" + }); + + await Assert.That(result.IsError).IsTrue(); + } +}