Locking the read access to the bucket makes all links to the resources to not have a public access. And depending on how you will use those S3 resources, you may need to provide a temporary way to access them. Such a way is to sign an AWS S3 link, which gives that link a specified lifespan of public access. After the link expires it becomes invalid and the resource is again protected from public accessing.
As a Salesforce guy/gal, you know there are a lot of limitations. One of them is the size of the memory during Apex execution, which limits us to implement a full backend solution for AWS S3 resource manipulation. No one wants resource size restrictions like a maximum files size of 12MB. Digging in that direction would be pointless and expensive. The other solution is to sign an AWS S3 resource link and allow the Salesforce user to access that resource for specified time, by calling simple and lightweight Apex.
I’m sure that you already found several very short code snippets that sign links. I was on the same boat. I’ve spent several hours testing snippets and digging for other solutions, but so far nothing worked for me. I ended up on AWS S3 API documentation and decided to get my hands dirty.
According to the documentation, in order to sign the link to an S3 resource, we need to create and append a signature to that link. The current signature version is v4, so that’s the version we are going to use in this article.
Before we get to the real signature code, we need to implement one helper function - UriEncode. I know that Apex already has one - EncodingUtil.urlEncode, but it differs a little bit and this makes it unsuitable for this signature generation - It does not have an option for not encoding the slash symbol, which is important here. Fortunately, AWS S3 documentation provides a source code in Java for that function with the desired option. Here’s its Apex equivalent:
String UriEncode(String input, Boolean encodeSlash) {
String result = '';
for (Integer i = 0; i < input.length(); i++) {
String ch = input.substring(i, i + 1);
if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a'
&& ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' ||
ch == '-' || ch == '~' || ch == '.') {
result += ch;
} else if (ch == '/') {
result += encodeSlash ? '%2F' : ch;
} else {
String hexValue = EncodingUtil.convertToHex(Blob.valueOf(ch)).toUpperCase();
if (hexValue.length() == 2) {
result += '%' + hexValue;
} else if (hexValue.length() == 4) {
result += '%' + hexValue.substring(0, 2) + '%' + hexValue.substring(2);
}
}
}
return result;
}
Here is the important method:
public static String getSignedURL(String accessKey, String secretKey, String bucketName, String bucketRegion, String location, String file, Integer expires) {
Datetime currentDateTime = Datetime.now();
String dateOnly = currentDateTime.formatGmt('yyyyMMdd');
String req = dateOnly + '/'+ bucketRegion +'/s3/aws4_request';
String xAmzCredentialStr = accessKey + '/' + req;
String xAmzDate = currentDateTime.formatGmt('yyyyMMdd\'T\'HHmmss\'Z\'');
String xAmzSignedHeaders = 'host';
String host = bucketName + '.s3.'+ bucketRegion +'.amazonaws.com';
String canonicalRequest =
'GET\n' +
'/' + UriEncode(file, false) + '\n' +
UriEncode('X-Amz-Algorithm', true) + '=' + UriEncode('AWS4-HMAC-SHA256', true) + '&' +
UriEncode('X-Amz-Credential', true) + '=' + UriEncode(xAmzCredentialStr, true) + '&' +
UriEncode('X-Amz-Date', true) + '=' + UriEncode(xAmzDate, true) + '&' +
UriEncode('X-Amz-Expires', true) + '=' + UriEncode(String.valueOf(expires), true) + '&' +
UriEncode('X-Amz-SignedHeaders', true) + '=' + UriEncode(xAmzSignedHeaders, true) + '\n' +
'host:'+host + '\n\n' +
'host\n' +
'UNSIGNED-PAYLOAD';
String stringToSign =
'AWS4-HMAC-SHA256\n'+
xAmzDate + '\n' +
req + '\n' +
EncodingUtil.convertToHex(
Crypto.generateDigest('SHA-256', Blob.valueOf(canonicalRequest))
);
Blob dateKey = Crypto.generateMac('hmacSHA256', Blob.valueOf(dateOnly), Blob.valueOf('AWS4' + secretKey));
Blob dateRegionKey = Crypto.generateMac('hmacSHA256', Blob.valueOf(bucketRegion), dateKey);
Blob dateRegionServiceKey = Crypto.generateMac('hmacSHA256', Blob.valueOf('s3'), dateRegionKey);
Blob signingKey = Crypto.generateMac('hmacSHA256', Blob.valueOf('aws4_request'), dateRegionServiceKey);
Blob signature = Crypto.generateMac('hmacSHA256', Blob.valueOf(stringToSign), signingKey);
String signatureStr = EncodingUtil.convertToHex(signature);
return location + '?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=' + EncodingUtil.urlEncode(xAmzCredentialStr, 'UTF-8') + '&X-Amz-Date=' + xAmzDate + '&X-Amz-Expires=' + String.valueOf(expires) +'&X-Amz-Signature=' + signatureStr + '&X-Amz-SignedHeaders=host';
}
The required parameters should be self-explanatory, but just in case here are their meanings:
- accessKey - the AWS S3 access key;
- secretKey - the AWS S3 secret key;
- bucketName - name of our target AWS S3 bucket;
- bucketRegion - region name for our AWS S3 bucket;
- location - full url to the desired file;
- file - it is the part after amazonaws.com without a leading slash;
- expires - link timeout in seconds which starts to count down at the end of getSignedUrl method invocation.
And at the end here is an example method invocation with test data:
String signedUrl = YourClassNameHere.getSignedURL('s3-access-key-here', 's3-secret-key-here', 'your-s3-bucket', 'eu-west-1', 'https://your-s3-bucket.s3.eu-west-1.amazonaws.com/some-folder/my-target-file.jpeg', 'some-folder/my-target-file.jpeg', 30);
It generates a signed link with a validity of 30 seconds.
No additional settings are required in Salesforce in order to use this AWS S3 link signing, except the storage of your AWS S3 credentials.
That’s all. Simple, isn’t it?
Happy signing ;)