Skip to content

Instantly share code, notes, and snippets.

@brianmfear
Last active June 5, 2024 17:39
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 });
}
}
@rchasman
Copy link

rchasman commented Jul 13, 2016

Thanks for posting this. I'm having some issues and working through them. The first issue that I resolved was with the date format on Line 182.

setHeader('date', requestTime.formatGmt('E, dd MMM YYYY HH:mm:ss z'));
<Message>Date must be in ISO-8601 'basic format'. Got 'Wed, 13 Jul 2016 18:08:36 GMT'. See http://en.wikipedia.org/wiki/ISO_8601</Message>

I resolved it with this:

setHeader('date', requestTime.formatGMT('YYYYMMdd\'T\'HHmmss\'Z\''));

@rchasman
Copy link

I got it to work! I needed the query params to not use .toLowerCase() on the keys...

Check out my forked copy for working code 🎉

@pvigil
Copy link

pvigil commented Apr 13, 2018

@brianmfear - One question, when it says 'This method helps prevent leaking secret key', another way to prevent leaking would be to store the secret as a private transient member right?

@manojkrishnaks
Copy link

While doing the URL encoding even '' is not getting encoded, encountered this issue while creating IAM policy. we need to replace '' with '%2A' like the code below
protected string uriEncode(String value) {
return value==null? null: EncodingUtil.urlEncode(value, 'utf-8').replaceAll('%7E','~').replaceAll('\+','%20').replaceAll('\*','%2A');
}

@tobias-truenorth
Copy link

Thanks for this! really helped us

@dennisschultz-db
Copy link

Has anyone had any success using this approach with services other than S3? I've been able to access S3 using the example in the comments. I'm trying to use the same approach with Transcribe (https://docs.aws.amazon.com/transcribe/latest/dg/API_StartTranscriptionJob.html) and haven't been able to figure out the correct way to specify the action. Currently, I'm getting "UnknownOperationException". I don't believe it is a problem with this Gist code but the way I am constructing the request. I haven't found good documentation on making REST calls for Transcribe - the docs push you toward the language-specific SDKs.

@zainabian
Copy link

zainabian commented Aug 27, 2019

Hi, i need some help.
I refactored this code to get url with query parameters so i can just pass the url to the client.
example: Using GET with Authentication Information in the Query String (Python)

following is the code snippet:
`String service = 's3';

    Datetime datetimeNow = Datetime.now();
    String amz_date = datetimeNow.formatGMT('YYYYMMdd\'T\'HHMMSS\'Z\''); // Format date as YYYYMMDD'T'HHMMSS'Z'
    String dateStamp = datetimeNow.formatGMT('YYYYMMdd');

    String signed_headers = 'host';
    String credential_scope = dateStamp + '/' + region + '/' + service + '/' + 'aws4_request';
    String canonical_querystring =  '&X-Amz-Algorithm=AWS4-HMAC-SHA256';
    canonical_querystring += '&X-Amz-Credential=' + EncodingUtil.urlEncode(accessKeyId + '/' + credential_scope, 'utf-8');
    canonical_querystring += '&X-Amz-Date=' + amz_date;
    canonical_querystring += '&X-Amz-Expires=86400'; //24 hours
    canonical_querystring += '&X-Amz-SignedHeaders=' + signed_headers;

    String parentName = <someData>;
    String filePath = bucket + '/' + parentName + '/' + parentId + '/' + externalFileName;
    String filePathEncoded = EncodingUtil.urlEncode(filePath,'UTF-8');

    String payload_hash = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', Blob.valueOf(EncodingUtil.urlEncode('','utf-8'))));
    String canonical_headers = 'host:' + String.format(S3_BASE_HOST, new List<String>{region}) + '\n';
    String canonical_request = 'GET' + '\n' + '/'+filePathEncoded + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + signed_headers + '\n' + payload_hash;


    String string_to_sign = 'AWS4-HMAC-SHA256' + '\n' +  amz_date + '\n' +  credential_scope + '\n' +  EncodingUtil.convertToHex(Crypto.generateDigest('sha256', Blob.valueOf(canonical_request)));

    String signature = EncodingUtil.convertToHex(Crypto.generateMac('hmacSHA256', Blob.valueOf(string_to_sign), getSignatureKey(secretAccessKey, datetimeNow, region, service)));
    canonical_querystring += '&X-Amz-Signature=' + signature;
    String request_url = String.format(S3_BASE_URL, new List<String>{region}) + filePathEncoded+'?' + canonical_querystring;

    return request_url;

Blob getSignatureKey(String secretKey, DateTime dateStampNoTime, String region, String service) {
Blob kSigning = Crypto.generateMac('hmacSHA256', Blob.valueOf('aws4_request'),
Crypto.generateMac('hmacSHA256', Blob.valueOf(service),
Crypto.generateMac('hmacSHA256', Blob.valueOf(region),
Crypto.generateMac('hmacSHA256', Blob.valueOf(dateStampNoTime.formatGMT('YYYYMMdd')), Blob.valueOf('AWS4' + secretKey))
)
)
);

    return kSigning;
}

`

When i execute the request_url var in the browser, i get ** SignatureDoesNotMatch** error.

`

--
  | SignatureDoesNotMatchThe request signature we calculated does not match the signature you provided. Check your key and signing method.AKIAUH4XYVF3MZMZE4XEAWS4-HMAC-SHA256
  | 20190827T0408849Z
  | 20190827/us-east-2/s3/aws4_request
  | d62f20326d0aac96ddfd0c3c835a673d3304d48c67d03de9bd31de01a0e4bc905c4e0c1966a7c1a0d925b6e114444b760425bd32a2a8a1d0335f3f87aceebd2641 57 53 34 2d 48 4d 41 43 2d 53 48 41 32 35 36 0a 32 30 31 39 30 38 32 37 54 30 34 30 38 38 34 39 5a 0a 32 30 31 39 30 38 32 37 2f 75 73 2d 65 61 73 74 2d 32 2f 73 33 2f 61 77 73 34 5f 72 65 71 75 65 73 74 0a 64 36 32 66 32 30 33 32 36 64 30 61 61 63 39 36 64 64 66 64 30 63 33 63 38 33 35 61 36 37 33 64 33 33 30 34 64 34 38 63 36 37 64 30 33 64 65 39 62 64 33 31 64 65 30 31 61 30 65 34 62 63 39 30GET
  | /sitetracker-ohio/strk__Site__c/a0v4F000000uJs8QAE/67403031_149234482806423_1650259995721203712_n.jpg
  | X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAUH4XYVF3MZMZE4XE%2F20190827%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20190827T0408849Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host
  | host:s3.us-east-2.amazonaws.com
  |  
  | host
  | UNSIGNED-PAYLOAD47 45 54 0a 2f 73 69 74 65 74 72 61 63 6b 65 72 2d 6f 68 69 6f 2f 73 74 72 6b 5f 5f 53 69 74 65 5f 5f 63 2f 61 30 76 34 46 30 30 30 30 30 30 75 4a 73 38 51 41 45 2f 36 37 34 30 33 30 33 31 5f 31 34 39 32 33 34 34 38 32 38 30 36 34 32 33 5f 31 36 35 30 32 35 39 39 39 35 37 32 31 32 30 33 37 31 32 5f 6e 2e 6a 70 67 0a 58 2d 41 6d 7a 2d 41 6c 67 6f 72 69 74 68 6d 3d 41 57 53 34 2d 48 4d 41 43 2d 53 48 41 32 35 36 26 58 2d 41 6d 7a 2d 43 72 65 64 65 6e 74 69 61 6c 3d 41 4b 49 41 55 48 34 58 59 56 46 33 4d 5a 4d 5a 45 34 58 45 25 32 46 32 30 31 39 30 38 32 37 25 32 46 75 73 2d 65 61 73 74 2d 32 25 32 46 73 33 25 32 46 61 77 73 34 5f 72 65 71 75 65 73 74 26 58 2d 41 6d 7a 2d 44 61 74 65 3d 32 30 31 39 30 38 32 37 54 30 34 30 38 38 34 39 5a 26 58 2d 41 6d 7a 2d 45 78 70 69 72 65 73 3d 38 36 34 30 30 26 58 2d 41 6d 7a 2d 53 69 67 6e 65 64 48 65 61 64 65 72 73 3d 68 6f 73 74 0a 68 6f 73 74 3a 73 33 2e 75 73 2d 65 61 73 74 2d 32 2e 61 6d 61 7a 6f 6e 61 77 73 2e 63 6f 6d 0a 0a 68 6f 73 74 0a 55 4e 53 49 47 4e 45 44 2d 50 41 59 4c 4f 41 44148589A6CDB13425Cb6IsaabcBWesJp5mv1mDKMCDOviF8JIPCJZYOY66V5qvzRuD3Ax995CBWT1ZzL8TkWyuxQ8XQ8=

`
What am I missing?

@Crysis2015
Copy link

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

@Xisku
Copy link

Xisku commented Dec 30, 2019

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!

@casheehan
Copy link

casheehan commented Dec 30, 2019

I had implemented this code and it was working like a charm until 5 days ago (12/25/2019). Ever since then, every request from apex to AWS fails with 403. I verified that I am still able to access the role via AWS CLI with the same credentials.
Does anyone know if there have been any recent changes to Apex or AWS that may have caused this class to start failing?

Edit: service I'm calling is STS AssumeRole

@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