AWS Open Source Blog
Introducing the AWS SigV4 Signer for Dart
The AWS Amplify team is pleased to announce the availability of the AWS SigV4 Signer for Dart, an open source implementation of the AWS Signature Version 4 protocol in the Dart programming language. The SigV4 signer is a library which developers can include in their projects to sign and send HTTP requests to AWS services using their AWS credentials. Check it out on pub.dev.
AWS Amplify is a set of tools and services that enables mobile and front-end web developers to build secure, scalable full stack applications powered by AWS. It consists of three main components: a set of open source libraries and UI components for adding cloud-powered functionalities, a CLI toolchain to create and manage cloud backends, and AWS Amplify Studio, a service to deploy and host full stack, serverless applications.
Flutter is Google’s UI toolkit for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase using the Dart programming language. AWS Amplify supports building full stack Flutter applications using the Amplify Flutter client libraries and CLI. While we continue to expand support for more AWS services in Amplify Flutter, the SigV4 signer provides developers a way to build custom features and integrations that interact directly with these AWS services before they’re supported out of the box.
Example
To see the signer in action, let’s walk through the creation of a bucket in Amazon Simple Storage Service (Amazon S3) and how you might upload and share links to files. The complete code for all the steps in this example is available here. We’ll start by creating a bucket and uploading a file to it. Then, we’ll look at how we can generate “pre-signed” URLs which we can share granting access to the file for a limited period of time.
Note: Following along will require access to AWS credentials with the following permissions:
–
s3:CreateBucket
–s3:PutObject
–s3:GetObject
While objects uploaded to buckets are private by default and require your credentials to access, that does not mean your buckets are guaranteed to be secure. Review the AWS documentation for securing buckets and/or consult a security expert before using Amazon S3 buckets in production.
Getting Started
To get started using the SigV4 signer, add it as a dependency in your pubspec.yaml
,
dependencies:
aws_common: ^0.2.0
aws_signature_v4: ^0.2.0
Then create an instance in your project.
import 'package:aws_signature_v4/aws_signature_v4.dart';
const signer = AWSSigV4Signer();
Credentials are configured in the signer by overriding the credentialsProvider
parameter of the constructor. By default, the signer pulls credentials from your environment via the AWSCredentialsProvider.environment()
provider. On mobile and web, this means using the Dart environment, which is configured by passing the --dart-define
flag to your flutter
commands, as below.
flutter run --dart-define=AWS_ACCESS_KEY_ID=... --dart-define=AWS_SECRET_ACCESS_KEY=...
.
On desktop, credentials are retrieved from the system’s environment using Platform.environment
.
Take care when handling credentials.
To retrieve credentials via another mechanism, pass a credentialsProvider
object to the signer’s constructor. There are a few available out of the box, but any class conforming to the AWSCredentialsProvider
interface can be used.
Creating a bucket
To create a bucket, we need to be able to call Amazon S3’s CreateBucket API. Amazon S3 uses a REST protocol and serializes its messages using XML. Since Dart does not provide XML support out of the box, we’ll write our requests by hand as strings — although, for larger requests, consider using a community package like xml.
Every signer action begins the same way: by defining the scope of the credentials it produces. This is broken down into three components: the ID of the service being accessed, the region of the service being accessed, and the time of the request (by default, the current time). For our requests to Amazon S3, that looks like the following:
const region = 'us-west-2'; // Choose any region you'd like final scope = AWSCredentialScope(region: region, service: 's3');
A list of supported regions for Amazon S3 can be found here.
We can reuse this scope in all of the requests in this example since they will be executed within the same window of time Signer outputs are only valid for 15 minutes after the time of the scope, so generally it is better to create a new scope for each request.
We’ll give our bucket a name and use virtual hosting to interact with it (even though it does not exist yet). You can choose any bucket name you’d like, but remember that Amazon S3 bucket names must be globally unique across all Amazon S3 buckets. For this example, we append a random integer to our bucket name to ensure uniqueness.
import 'dart:math'; final bucketName = 'mybucket-${Random().nextInt(1 << 30)}'; final host = '$bucketName.s3.$region.amazonaws.com';
Also, unlike many other services, Amazon S3 has some specifics when it comes to signing. The signing package encapsulates that logic for us by using service configurations.
If you want to learn more about signing details, you can check the details here.
final serviceConfiguration = S3ServiceConfiguration();
Now we’re ready to create an HTTP request which can be passed to the signer for signing. Following the request syntax described in the API docs, we construct an AWSHttpRequest
and pass in our XML body as a UTF-8 encoded string.
If you’re familiar with the request/response types of the HTTP package, the AWS types are meant to mirror these. There are
AWSHttpRequest/Response
types as well asAWSStreamedHttpRequest/Response
counterparts for streaming requests.
Amazon S3 expects a few headers to be present in our request, most notably the Host
header, which is required by most AWS services. It also expects that the Content-Length
and Content-Type
headers are present, so we include those here.
import 'dart:convert'; import 'package:aws_common/aws_common.dart'; // The body of the request, which specifies the bucket's location final createBody = utf8.encode(''' <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <LocationConstraint>$region</LocationConstraint> </CreateBucketConfiguration> '''); // Create a bucket by making a PUT request to the root of the bucket final createRequest = AWSHttpRequest( method: HttpMethod.put, host: host, path: '/', body: createBody, headers: { AWSHeaders.host: host, // Remember to include the content length and content type // for XML requests to S3 AWSHeaders.contentLength: createBody.length.toString(), AWSHeaders.contentType: 'application/xml', }, );
To sign the request, we simply pass it to the signer, getting back a new HTTP request with the required headers needed to authorize us to AWS servers.
// Sign and send the PUT request final signedCreateRequest = await signer.sign( createRequest, credentialScope: scope, serviceConfiguration: serviceConfiguration, );
We can send the request by calling send
.
final createResponse = await signedCreateRequest.send();
Finally, we check to make sure it was successful. Since bucket names must be unique, we’ll get an error if we happened to pick a name which is already in use. In this case, Amazon S3 returns the 409
or Conflict
status code. Any other code besides 200
means our request failed in some way.
final createStatus = createResponse.statusCode; print('Create Bucket Response: $createStatus'); if (createStatus == 409) { print('Bucket name already exists!'); } else if (createStatus != 200) { print('Bucket creation failed'); }
A list of all error codes can be found here.
Uploading a file
Assuming you got a 200
status code from the create request, we’re ready to upload a file to our bucket. If not, consult the Amazon S3 docs for help troubleshooting the error code you received.
To upload a file to our bucket, we need to call the PutObject API, which expects a PUT
request to the file’s location in the bucket with the contents of the file as the body. We can open a file for signing by calling the openRead
method on a File
instance. We’ll place the file at the root of our bucket by assigning it a bucket key of /$filename
— this will become the path of our HTTP request to upload the file.
// Create a file and write some contents to it. Then, open it // for reading. const filename = 'myfile.txt'; final file = File(filename)..writeStringSync('my file'); final contents = file.openRead(); const key = '/$filename';
The process from here is the same as before, except that this time we use the AWSStreamedHttpRequest
constructor since we are working directly with a Stream
now. We also leave out the Content-Length
header, since the signer will add it automatically for us if not included.
// Create a PUT request to the path of the file. final uploadRequest = AWSStreamedHttpRequest( method: HttpMethod.put, host: host, path: key, body: contents, headers: { AWSHeaders.host: host, AWSHeaders.contentType: 'text/plain', }, ); // Sign and send the upload request final signedUploadRequest = await signer.sign( uploadRequest, credentialScope: scope, serviceConfiguration: serviceConfiguration, ); final uploadResponse = await signedUploadRequest.send(); final uploadStatus = uploadResponse.statusCode; print('Upload File Response: $uploadStatus'); if (uploadStatus != 200) { print('Could not upload file'); }
Creating a pre-signed URL
Now that we’ve uploaded the file, maybe we need to send a link for others to access it, or maybe one of our own applications needs to display its contents. In order to do so, we can create a pre-signed URL which allows anyone with the URL to access the contents of the file for a preset period of time (between 1 second and 7 days).
In order to do so, we’ll sign a GET
request for the URL of the file, but instead of calling signer.sign
, we’ll use signer.presign
which will attach the credentials as query parameters to the URL instead of as headers. This means that only the URL will be needed to access the file.
// Construct the URI we'd like to sign as an HTTP request final urlRequest = AWSHttpRequest( method: HttpMethod.get, host: host, path: key, headers: { AWSHeaders.host: host, }, ); // Use the signer's presign function to create a signed URI final signedUrl = await signer.presign( urlRequest, credentialScope: scope, serviceConfiguration: serviceConfiguration, expiresIn: const Duration(minutes: 10), ); print('Download URL: $signedUrl');
This will print a long URL, which looks like the one below, that can be used as many times as needed until its expiration.
https://mybucket-754375478539575.s3.us-west-2.amazonaws.com/myfile.txt?X-Amz-Date=20220328T210444Z&X-Amz-SignedHeaders=host&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAR7FIPFYKSVQ32MET%2F20220328%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Expires=600&X-Amz-Signature=a90c8b9df4f50458513b7f4883dd1c1203f2753d565a3680a81c3164e3936b5a
Wrapping up
We’ve walked through the process of creating an Amazon S3 bucket, uploading a file to it and sharing access to that file via pre-signed URLs.
Make sure to delete the file and bucket from Amazon S3 using the AWS console or the AWS CLI if you no longer need it.
The Dart V4 signer is the first step that the Amplify Flutter team is taking towards expanding platform support using Dart, unlocking web and desktop support for Amplify Flutter developers.
We’re excited to bring this bit of functionality to the Dart ecosystem and look forward to seeing how people incorporate the signer into their Dart and Flutter projects. Let us know what you’re building next on Discord. Feel free to leave any feedback on our GitHub issues board and any of the active community discussions around web and desktop support. Review our open request for comments (RFC) for updates to the API category.