Skip to content

Instantly share code, notes, and snippets.

@patrick-fischer
Last active April 7, 2021 15:01
Show Gist options
  • Save patrick-fischer/cec45adfdb83dd97ab806215b8a2467b to your computer and use it in GitHub Desktop.
Save patrick-fischer/cec45adfdb83dd97ab806215b8a2467b to your computer and use it in GitHub Desktop.
AWS Authentication using Query Parameters (AWS Signature Version 4): https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
/**
// Example implementation as follows:
public class AWSS3_GetService extends AWS {
public override void init() {
resource = '/[your bucket]/[your folder 1]/[your folder 2]/[file name].[extension]';
region = 'eu-west-2';
service = 's3';
endpoint = new Url('https://' + service + '.' + region + '.amazonaws.com/');
accessKey = 'SET HERE';
method = HttpMethod.XGET;
createSigningKey('SET HERE');
}
public String yourMethod() {
String url = getPreSignedUrl();
return url;
}
}
*/
public abstract class AWS {
// Post initialization logic (after constructor, before call)
protected abstract void init();
// Things we need to know about the service. Set these values in init()
protected String host;
protected String region;
protected String service;
protected String resource;
protected String accessKey;
protected String payloadSha256;
protected String contentType;
protected Url endpoint;
protected HttpMethod method;
protected Blob payload;
protected Integer expires;
// Not used externally, so we hide these values
Blob signingKey;
String signature;
String canonicalRequest;
DateTime requestTime;
Map<String, String> queryParams;
Map<String, String> headerParams;
public enum HttpMethod {XGET, XPUT, XHEAD, XOPTIONS, XDELETE, XPOST}
// Call this constructor with super() in subclasses
protected AWS() {
requestTime = DateTime.now();
queryParams = new Map<String, String>();
headerParams = new Map<String, String>();
expires = 10;
}
// Send a default request
protected HttpResponse sendRequest() {
return sendRequest(new Set<Integer> { 200 });
}
// Get URL to download file
protected String getPreSignedUrl() {
return createQuerySignedRequest().getEndpoint();
}
// Actually perform the request, and throw exception if response code is not valid
protected HttpResponse sendRequest(Set<Integer> validCodes) {
HttpRequest request = createQuerySignedRequest();
HttpResponse response = new Http().send(request);
if(!validCodes.contains(response.getStatusCode())) {
throw new ServiceException(response.getBodyDocument().getRootElement());
}
return response;
}
HttpRequest createQuerySignedRequest() {
String stringToSign;
String finalEndpoint;
String queryString;
String canonicalHeaders;
HttpRequest request = new HttpRequest();
init();
request.setMethod(method.name().removeStart('X'));
if(payload == null || payload == Blob.valueOf('UNSIGNED-PAYLOAD')) {
payloadSha256 = 'UNSIGNED-PAYLOAD';
} else {
payloadSha256 = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', payload));
request.setBodyAsBlob(payload);
}
if(host == null) {
host = service + '.' + region + '.amazonaws.com';
}
if(String.isNotBlank(contentType)) {
request.setHeader('Content-type', contentType);
setHeader('Content-type', contentType);
}
setHeader('Host', host);
//Set AUTHPARAMS in the query
setQueryParam('X-Amz-Algorithm','AWS4-HMAC-SHA256');
setQueryParam('X-Amz-Credential', String.join(new List<String> {
accessKey,
requestTime.formatGMT('yyyyMMdd'),
region,
service,
'aws4_request'
},'/'));
setQueryParam('X-Amz-Date',requestTime.formatGMT('yyyyMMdd\'T\'HHmmss\'Z\''));
setQueryParam('X-Amz-Expires', String.valueOf(expires));
setQueryParam('X-Amz-SignedHeaders', + String.join(new List<String>(headerParams.keySet()),';').toLowerCase());
//Create the signature
queryString = createCanonicalQueryString();
canonicalHeaders = createCanonicalHeaders();
canonicalRequest = createCanonicalRequest(queryString, canonicalHeaders);
stringToSign = createStringToSign(canonicalRequest);
createSignature(stringToSign);
//Add the signature at the end
queryString += '&X-Amz-Signature=' + signature;
finalEndpoint = new Url(endpoint, resource).toExternalForm() + '?'+ queryString;
System.debug('finalEndpoint: ' + finalEndpoint);
request.setEndpoint(finalEndpoint);
return request;
}
protected void createSigningKey(String secretKey) {
Blob dateKey = signString(Blob.valueOf(requestTime.formatGMT('yyyyMMdd')),Blob.valueOf('AWS4'+secretKey));
Blob dateRegionKey = signString(Blob.valueOf(region),dateKey);
Blob dateRegionServiceKey = signString(Blob.valueOf(service),dateRegionKey);
signingKey = signString(Blob.valueOf('aws4_request'),dateRegionServiceKey);
}
// Create a canonical query string (used during signing)
String createCanonicalQueryString() {
List<String> results = new List<String>();
List<String> 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() {
List<String> results = new List<String>();
List<String> keys = new List<String>(headerParams.keySet());
keys.sort();
for(String key: keys) {
results.add(key + ':' + headerParams.get(key));
}
return String.join(results, '\n')+'\n';
}
// Create the entire canonical request
String createCanonicalRequest(String queryString, String canonicalHeaders) {
String result = String.join(
new List<String> {
method.name().removeStart('X'), // METHOD
new Url(endPoint, resource).getPath(), // RESOURCE
queryString, // CANONICAL QUERY STRING
canonicalHeaders, // CANONICAL HEADERS
String.join(new List<String>(headerParams.keySet()), ';'), // SIGNED HEADERS
payloadSha256 // SHA256 PAYLOAD
},
'\n');
return result;
}
// Create the entire string to sign
String createStringToSign(String canonicalRequest) {
String result = String.join(
new List<String> {
'AWS4-HMAC-SHA256',
requestTime.formatGMT('yyyyMMdd\'T\'HHmmss\'Z\''),
String.join(new List<String>{requestTime.formatGMT('yyyyMMdd'), region, service, 'aws4_request' },'/'),
EncodingUtil.convertToHex(Crypto.generateDigest('sha256', Blob.valueof(canonicalRequest)))
},
'\n'
);
return result;
}
private void createSignature(String stringToSign){
signature = EncodingUtil.convertToHex(signString(blob.valueof(stringToSign),signingKey));
}
// 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');
}
protected void setHeader(String key, String value) {
headerParams.put(key.toLowerCase(), value);
}
protected void setQueryParam(String key, String value) {
queryParams.put(key.capitalize(), UriEncode(value));
}
private Blob signString(Blob msg, Blob key) {
return Crypto.generateMac('HMACSHA256', msg, key);
}
private 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);
}
}
}
@babunirwan
Copy link

babunirwan commented Jul 14, 2020

getPresignedUrl method is working for some keys only. For others it gives signature error.

Code implemented -
public override void init() {

    resource = '/connect-824003c3af69/connect/mirage-demo/CallRecordings/2020/07/07/2bb900fb-2fb4-4960-b766-0b34fa131ded_20200707T06:58_UTC.wav';
    region = 'eu-central-1';
    service = 's3';
    endpoint = new Url('https://' + service + '.' + region + '.amazonaws.com/');
    accessKey = 'AK';
    method = HttpMethod.XGET;
    createSigningKey('SK');
}
public String yourMethod() {
    String url = getPreSignedUrl();
    System.Debug(url);
    return url;
}

Can you suggest some change so that it works for all.Bucket name - connect-824003c3af69
Did some analysis - : in the filename is causing issue.

SignatureDoesNotMatch The request signature we calculated does not match the signature you provided. Check your key and signing method. AKIA3LQP6CCJV6OQTXRG AWS4-HMAC-SHA256 20200714T134643Z 20200714/eu-central-1/s3/aws4_request 7578e681654824205caea67c0889436cf0aba415a541c886e503391fba73ef8b 61be533743425f5f577b13f4700c52d32a152398456a132088f606f4b68459f3 41 57 53 34 2d 48 4d 41 43 2d 53 48 41 32 35 36 0a 32 30 32 30 30 37 31 34 54 31 33 34 36 34 33 5a 0a 32 30 32 30 30 37 31 34 2f 65 75 2d 63 65 6e 74 72 61 6c 2d 31 2f 73 33 2f 61 77 73 34 5f 72 65 71 75 65 73 74 0a 37 35 37 38 65 36 38 31 36 35 34 38 32 34 32 30 35 63 61 65 61 36 37 63 30 38 38 39 34 33 36 63 66 30 61 62 61 34 31 35 61 35 34 31 63 38 38 36 65 35 30 33 33 39 31 66 62 61 37 33 65 66 38 62 GET /connect-824003c3af69/connect/mirage-demo/CallRecordings/2020/07/01/69b8dc8d-f54d-4cb2-b94a-d4f0bd932d4a_20200701T13%3A14_UTC.wav X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA3LQP6CCJV6OQTXRG%2F20200714%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20200714T134643Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host host:s3.eu-central-1.amazonaws.com host UNSIGNED-PAYLOAD 47 45 54 0a 2f 63 6f 6e 6e 65 63 74 2d 38 32 34 30 30 33 63 33 61 66 36 39 2f 63 6f 6e 6e 65 63 74 2f 6d 69 72 61 67 65 2d 64 65 6d 6f 2f 43 61 6c 6c 52 65 63 6f 72 64 69 6e 67 73 2f 32 30 32 30 2f 30 37 2f 30 31 2f 36 39 62 38 64 63 38 64 2d 66 35 34 64 2d 34 63 62 32 2d 62 39 34 61 2d 64 34 66 30 62 64 39 33 32 64 34 61 5f 32 30 32 30 30 37 30 31 54 31 33 25 33 41 31 34 5f 55 54 43 2e 77 61 76 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 33 4c 51 50 36 43 43 4a 56 36 4f 51 54 58 52 47 25 32 46 32 30 32 30 30 37 31 34 25 32 46 65 75 2d 63 65 6e 74 72 61 6c 2d 31 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 32 30 30 37 31 34 54 31 33 34 36 34 33 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 65 75 2d 63 65 6e 74 72 61 6c 2d 31 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 44 FAFE1152D1D29DAB yiSh3HYxAJETY9/0Cu6crZpXZZqzgUzA2we+k3tvRE933GMGkKGq3iqGfgK9ThHxrZ8RMNHLoWQ=

If you see in canonical request : is converted to %3A but in final url its not converted so it gives this signature mismatch thing. Is it possible you can fix this in your code. It works only if in the resource in init initialisation i replace : with %3A. can you suggest possible fix to fix all these characters issue

@klthaw
Copy link

klthaw commented Nov 4, 2020

Hi babunirwan,
Any solution for SignatureDoesNotMatch error?
Regards,
LinThaw

@olgaslcode
Copy link

olgaslcode commented Mar 15, 2021

Hi babunirwan,
Any solution for SignatureDoesNotMatch error?
Regards,
LinThaw

I will implement this class and will let you know if fix it.

@patrik-fisher Great work! Thank you, man.

@jheidt
Copy link

jheidt commented Apr 7, 2021

@OlgasCode im guessing its the janky handrolled URLEncoding found on line 205.

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