Skip to content

Instantly share code, notes, and snippets.

@brianmfear
Last active June 19, 2024 18:36
Show Gist options
  • Save brianmfear/92cf05807ac4becbd21f to your computer and use it in GitHub Desktop.
Save brianmfear/92cf05807ac4becbd21f to your computer and use it in GitHub Desktop.
Abstract AWS implementation in Apex Code
/*
// Example implementation as follows:
public class AWSS3_GetService extends AWS {
public override void init() {
endpoint = new Url('https://s3.amazonaws.com/');
resource = '/';
region = 'us-east-1';
service = 's3';
accessKey = 'my-key-here';
method = HttpMethod.XGET;
// Remember to set "payload" here if you need to specify a body
// payload = Blob.valueOf('some-text-i-want-to-send');
// This method helps prevent leaking secret key,
// as it is never serialized
createSigningKey('my-secret-key-here');
}
public String[] getBuckets() {
HttpResponse response = sendRequest();
String[] results = new String[0];
// Read response XML; if we get this far, no exception happened
// This code was omitted for brevity
return results;
}
}
*/
public abstract class AWS {
// Post initialization logic (after constructor, before call)
protected abstract void init();
// XML Node utility methods that will help read elements
public static Boolean getChildNodeBoolean(Dom.XmlNode node, String ns, String name) {
try {
return Boolean.valueOf(node.getChildElement(name, ns).getText());
} catch(Exception e) {
return null;
}
}
public static DateTime getChildNodeDateTime(Dom.XmlNode node, String ns, String name) {
try {
return (DateTime)JSON.deserialize(node.getChildElement(name, ns).getText(), DateTime.class);
} catch(Exception e) {
return null;
}
}
public static Integer getChildNodeInteger(Dom.XmlNode node, String ns, String name) {
try {
return Integer.valueOf(node.getChildElement(name, ns).getText());
} catch(Exception e) {
return null;
}
}
public static String getChildNodeText(Dom.XmlNode node, String ns, String name) {
try {
return node.getChildElement(name, ns).getText();
} catch(Exception e) {
return null;
}
}
// Turns an Amazon exception into something we can present to the user/catch
public class ServiceException extends Exception {
public String Code, Message, Resource, RequestId;
public ServiceException(Dom.XmlNode node) {
String ns = node.getNamespace();
Code = getChildNodeText(node, ns, 'Code');
Message = getChildNodeText(node, ns, 'Message');
Resource = getChildNodeText(node, ns, 'Resource');
RequestId = getChildNodeText(node, ns, 'RequestId');
}
public String toString() {
return JSON.serialize(this);
}
}
// Things we need to know about the service. Set these values in init()
protected String host, region, service, resource, accessKey, payloadSha256;
protected Url endpoint;
protected HttpMethod method;
protected Blob payload;
// Not used externally, so we hide these values
Blob signingKey;
DateTime requestTime;
Map<String, String> queryParams, headerParams;
// Make sure we can't misspell methods
public enum HttpMethod { XGET, XPUT, XHEAD, XOPTIONS, XDELETE, XPOST }
// Add a header
protected void setHeader(String key, String value) {
headerParams.put(key.toLowerCase(), value);
}
// Add a query param
protected void setQueryParam(String key, String value) {
queryParams.put(key.toLowerCase(), uriEncode(value));
}
// Call this constructor with super() in subclasses
protected AWS() {
requestTime = DateTime.now();
queryParams = new Map<String, String>();
headerParams = new Map<String, String>();
payload = Blob.valueOf('');
}
// Create a canonical query string (used during signing)
String createCanonicalQueryString() {
String[] results = new String[0], keys = new List<String>(queryParams.keySet());
keys.sort();
for(String key: keys) {
results.add(key+'='+queryParams.get(key));
}
return String.join(results, '&');
}
// Create the canonical headers (used for signing)
String createCanonicalHeaders(String[] keys) {
keys.addAll(headerParams.keySet());
keys.sort();
String[] results = new String[0];
for(String key: keys) {
results.add(key+':'+headerParams.get(key));
}
return String.join(results, '\n')+'\n';
}
// Create the entire canonical request
String createCanonicalRequest(String[] headerKeys) {
return String.join(
new String[] {
method.name().removeStart('X'), // METHOD
new Url(endPoint, resource).getPath(), // RESOURCE
createCanonicalQueryString(), // CANONICAL QUERY STRING
createCanonicalHeaders(headerKeys), // CANONICAL HEADERS
String.join(headerKeys, ';'), // SIGNED HEADERS
payloadSha256 // SHA256 PAYLOAD
},
'\n'
);
}
// We have to replace ~ and " " correctly, or we'll break AWS on those two characters
protected string uriEncode(String value) {
return value==null? null: EncodingUtil.urlEncode(value, 'utf-8').replaceAll('%7E','~').replaceAll('\\+','%20');
}
// Create the entire string to sign
String createStringToSign(String[] signedHeaders) {
String result = createCanonicalRequest(signedHeaders);
return String.join(
new String[] {
'AWS4-HMAC-SHA256',
headerParams.get('date'),
String.join(new String[] { requestTime.formatGMT('YYYYMMdd'), region, service, 'aws4_request' },'/'),
EncodingUtil.convertToHex(Crypto.generateDigest('sha256', Blob.valueof(result)))
},
'\n'
);
}
// Create our signing key
protected void createSigningKey(String secretKey) {
signingKey = Crypto.generateMac('hmacSHA256', Blob.valueOf('aws4_request'),
Crypto.generateMac('hmacSHA256', Blob.valueOf(service),
Crypto.generateMac('hmacSHA256', Blob.valueOf(region),
Crypto.generateMac('hmacSHA256', Blob.valueOf(requestTime.formatGMT('YYYYMMdd')), Blob.valueOf('AWS4'+secretKey))
)
)
);
}
// Create all of the bits and pieces using all utility functions above
HttpRequest createRequest() {
init();
payloadSha256 = EncodingUtil.convertToHex(Crypto.generateDigest('sha-256', payload));
setHeader('x-amz-content-sha256', payloadSha256);
setHeader('date', requestTime.formatGmt('E, dd MMM YYYY HH:mm:ss z'));
if(host == null) {
host = endpoint.getHost();
}
setHeader('host', host);
HttpRequest request = new HttpRequest();
request.setMethod(method.name().removeStart('X'));
if(payload.size() > 0) {
setHeader('Content-Length', String.valueOf(payload.size()));
request.setBodyAsBlob(payload);
}
String
finalEndpoint = new Url(endpoint, resource).toExternalForm(),
queryString = createCanonicalQueryString();
if(queryString != '') {
finalEndpoint += '?'+queryString;
}
request.setEndpoint(finalEndpoint);
for(String key: headerParams.keySet()) {
request.setHeader(key, headerParams.get(key));
}
String[] headerKeys = new String[0];
String stringToSign = createStringToSign(headerKeys);
request.setHeader(
'Authorization',
String.format(
'AWS4-HMAC-SHA256 Credential={0},SignedHeaders={1},Signature={2}',
new String[] {
String.join(new String[] { accessKey, requestTime.formatGMT('YYYYMMdd'), region, service, 'aws4_request' },'/'),
String.join(headerKeys,';'), EncodingUtil.convertToHex(Crypto.generateMac('hmacSHA256', Blob.valueOf(stringToSign), signingKey))}
));
return request;
}
// Actually perform the request, and throw exception if response code is not valid
protected HttpResponse sendRequest(Set<Integer> validCodes) {
HttpResponse response = new Http().send(createRequest());
if(!validCodes.contains(response.getStatusCode())) {
throw new ServiceException(response.getBodyDocument().getRootElement());
}
return response;
}
// Same as above, but assume that only 200 is valid
// This method exists because most of the time, 200 is what we expect
protected HttpResponse sendRequest() {
return sendRequest(new Set<Integer> { 200 });
}
}
@casheehan
Copy link

Looks like the problem I experienced was due to the following :

requestTime.formatGmt('YYYYMMdd'T'HHmmss'Z''));

'YYYY' outputs, for whatever reason, the week year as opposed to the calendar year. This means that all requests I was making from Sunday onward had a year value of 2020. a Replacing all 'YYYY' occurrences with 'yyyy' appears to fix the issue

@savage25
Copy link

THANK YOU! This is amazing!

Note: I also had to change the date to:
setHeader('date', requestTime.formatGMT('YYYYMMdd\'T\'HHmmss\'Z\''));

@patrick-fischer
Copy link

patrick-fischer commented Apr 13, 2020

Anyone able to get the above code to work to generate pre signed URLs?

@Crysis2015 I adapted this to pre-signed URLs here:
https://blackbirdcrew.github.io/blog/aws/signature/salesforce/apex/2019/10/10/Calculating-AWS-Signature-Version-4-in-Salesforce-Apex.html

Hope it helps!

@Xisku I am trying to make your solution for pre-signed URLs work, however, facing the typical SignatureDoesNotMatch 403 error.

public override void init() {
   endpoint = new Url('https://[mybucket].s3.amazonaws.com/');
   resource = '/myFolder1/myFolder2/file.pdf';
   region = 'eu-west-2';
   service = 's3';
   accessKey = 'SET HERE';
   method = HttpMethod.XGET;
   payload = Blob.valueOf('UNSIGNED-PAYLOAD');
   contentType = 'application/pdf'; // disregard when using Authorization Header approach for AWS Sign v4
   createSigningKey('SET HERE');
}

Does anyone have a working solution with pre-signed URLs (i.e. Query Parameters approach)?
(FYI, the above gist works perfectly using the Authorization Header approach - omitting contentType.)

@Xisku
Copy link

Xisku commented Apr 13, 2020

@Xisku I am trying to make your solution for pre-signed URLs work, however, facing the typical SignatureDoesNotMatch 403 error.

@patrick-fischer Would need to see debugs, but can be because S3 is more tricky and has some exceptions, specially because of the bucket. Would require a v2 of the code.

First I would check is the endpoint, as the bucket for presigned URLs in S3 should be part of the path but, on the other side, included as part of the host header.

Check this:
https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html

@patrick-fischer
Copy link

Thanks @Xisku - I made some minor changes which fixed your pre-signed approach for me:

  • exclude contentType if blank
  • exclude payload when not needed (i.e. for S3 request we don't need to set a payload -> set to 'UNSIGNED-PAYLOAD' but don't convert to Hex)
  • add X-Amz-Expires query param (required for pre-signed approach)
  • added getPreSignedUrl() method (needed for my scenario to enabled user-downloads - front-end)

You can find my extended approach here:
https://gist.github.com/patrick-fischer/cec45adfdb83dd97ab806215b8a2467b

@Xisku
Copy link

Xisku commented Apr 13, 2020

@patrick-fischer glad it worked!

@mekyush
Copy link

mekyush commented Jun 17, 2020

@patrick-fischer Need you favour. I am trying to signing request for AWS API Gateway and for that also logic would be same as per the below document. But somehow i am getting 403 for it. have my code on gist

https://docs.aws.amazon.com/apigateway/api-reference/signing-requests/

@savage25
Copy link

formatGMT('YYYYMMdd') needs to be formatGMT('yyyyMMdd')
as "YYYY" is the "Week Year" and returns 2021 during these last few days of 2020.

@evanmontigney
Copy link

Referencing this code helped me out a great deal in updating our own AWS integration, was able to adapt this for use with SES, thank you for sharing.

@tarikMTI
Copy link

Thanks, @patrick-fischer. I have successfully generated presigned url using and it's working for both put and get requests.
Is it possible to use presigned url for copyobject request?
If so how should I modify the above gist?

@kalikotaa
Copy link

kalikotaa commented Jun 15, 2021

@brianmfear
With above code I could able to get all buckets from Amazon S3,
Could you also please share how to get a specific file from Amazon S3.

Im Using below code but still getting all the buckets info only

public class AWSS3_GetService extends AWS { public override void init() { endpoint = new Url('https://my-bucket-raj.s3.us-east-2.amazonaws.com/screenshot.png'); resource = '/'; region = 'us-east-2'; service = 's3'; accessKey = 'XXXXXXXXX';//my org method = HttpMethod.XGET; createSigningKey('XXXXXXXX'); } public String[] getBuckets() { HttpResponse response = sendRequest(); return results; } }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment