Skip to content

Commit

Permalink
Merge pull request #108 from nisuraaa/presigned-urls
Browse files Browse the repository at this point in the history
Add functions for creating AWS S3 Presigned URLs for GET and PUT requests
  • Loading branch information
NipunaRanasinghe authored Apr 5, 2024
2 parents 63bca86 + 9da3f80 commit 053b46a
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 12 deletions.
84 changes: 82 additions & 2 deletions ballerina/client.bal
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public isolated client class Client {
public isolated function init(ConnectionConfig config) returns error? {
self.region = (config?.region is string) ? <string>(config?.region) : DEFAULT_REGION;
self.amazonHost = self.region != DEFAULT_REGION ? regex:replaceFirst(AMAZON_AWS_HOST, SERVICE_NAME,
SERVICE_NAME + "." + self.region) : AMAZON_AWS_HOST;
SERVICE_NAME + "." + self.region) : AMAZON_AWS_HOST;
string baseURL = HTTPS + self.amazonHost;
self.accessKeyId = config.accessKeyId;
self.secretAccessKey = config.secretAccessKey;
Expand Down Expand Up @@ -83,7 +83,7 @@ public isolated client class Client {
if (cannedACL != ()) {
requestHeaders[X_AMZ_ACL] = cannedACL.toString();
}
if(self.region != DEFAULT_REGION) {
if (self.region != DEFAULT_REGION) {
xml xmlPayload = xml `<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<LocationConstraint>${self.region}</LocationConstraint>
</CreateBucketConfiguration>`;
Expand Down Expand Up @@ -261,6 +261,86 @@ public isolated client class Client {
http:Response httpResponse = check self.amazonS3->delete(requestURI, request);
return handleHttpResponse(httpResponse);
}

# Generates a presigned URL for the object.
#
# + bucketName - The name of the bucket
# + objectName - The name of the object
# + action - The action to be done on the object (`RETRIEVE` for object retrieval or `CREATE` for object creation)
# or the relevant headers for object retrieval or creation
# + expires - The time period for which the presigned URL is valid, in seconds
# + partNo - The part number of the object, when uploading multipart objects
# + uploadId - The upload ID of the multipart upload
# + return - If successful, a presigned URL, else an error
@display {label: "Create Presigned URL"}
remote isolated function createPresignedUrl(
@display {label: "Bucket Name"} string bucketName,
@display {label: "Object Name"} string objectName,
@display {label: "Object retrieval or creation indication with optional headers"}
ObjectAction|ObjectCreationHeaders|ObjectRetrievalHeaders action,
@display {label: "Expiration Time"} int expires = 1800,
@display {label: "Part Number"} int? partNo = (),
@display {label: "Upload ID"} string? uploadId = ())
returns string|error {

if expires < 0 {
return error(EXPIRATION_TIME_ERROR_MSG);
}
if objectName == EMPTY_STRING {
return error(EMPTY_OBJECT_NAME_ERROR_MSG);
}
if bucketName == EMPTY_STRING {
return error(EMPTY_BUCKET_NAME_ERROR_MSG);
}

[string, string] [amzDateStr, shortDateStr] = check generateDateString();

map<string> requestHeaders = {
[HOST] : self.amazonHost
};

GET|PUT httpMethod;
if action is CREATE || action is ObjectCreationHeaders {
httpMethod = PUT;
if action is ObjectCreationHeaders {
populateCreateObjectHeaders(requestHeaders, action);
}
} else {
httpMethod = GET;
if action is ObjectRetrievalHeaders {
populateGetObjectHeaders(requestHeaders, action);
}
}

[string, string] [canonicalHeaders, signedHeaders] = generateCanonicalHeaders(requestHeaders, ());

map<string> queryParams = {
[X_AMZ_ALGORITHM]: AWS4_HMAC_SHA256,
[X_AMZ_CREDENTIAL]: string `${self.accessKeyId}/${shortDateStr}/${self.region}/${SERVICE_NAME}/${
TERMINATION_STRING}`,
[X_AMZ_DATE]: amzDateStr,
[X_AMZ_EXPIRES]: expires.toString(),
[X_AMZ_SIGNED_HEADERS]: signedHeaders
};

string|error canonicalQuery = generateCanonicalQueryString(queryParams);
if canonicalQuery is error {
return error(CANONICAL_QUERY_STRING_GENERATION_ERROR_MSG, canonicalQuery);
}
string canonicalQueryString = canonicalQuery;

if partNo != () && uploadId != () && httpMethod == PUT {
canonicalQueryString = string `${canonicalQueryString}&partNumber=${partNo}&uploadId=${uploadId}`;
}
canonicalQueryString = re `/`.replaceAll(canonicalQueryString, "%2F");
string canonicalRequest = string `${httpMethod}${"\n"}/${bucketName}${string `/${objectName}`}${"\n"}${
canonicalQueryString}${"\n"}${canonicalHeaders}${"\n"}${signedHeaders}${"\n"}${UNSIGNED_PAYLOAD}`;
string stringToSign = generateStringToSign(amzDateStr, shortDateStr, self.region, canonicalRequest);
string signature = check constructPresignedUrlSignature(self.accessKeyId, self.secretAccessKey, shortDateStr,
self.region, stringToSign);
return string `${HTTPS}${self.amazonHost}/${bucketName}/${objectName}?${canonicalQueryString}&${X_AMZ_SIGNATURE
}=${signature}`;
}
}

isolated function setDefaultHeaders(string amazonHost) returns map<string> {
Expand Down
16 changes: 16 additions & 0 deletions ballerina/constants.bal
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ const string IF_MATCH = "If-Match";
const string IF_NONE_MATCH = "If-None-Match";
const string RANGE = "Range";
const string AUTHORIZATION= "Authorization";
const X_AMZ_EXPIRES = "X-Amz-Expires";
const X_AMZ_ALGORITHM = "X-Amz-Algorithm";
const X_AMZ_CREDENTIAL = "X-Amz-Credential";
const X_AMZ_SIGNED_HEADERS = "X-Amz-SignedHeaders";
const X_AMZ_SIGNATURE = "X-Amz-Signature";

// HTTP verbs.
const string GET = "GET";
Expand All @@ -71,3 +76,14 @@ const string CANONICAL_URI_GENERATION_ERROR_MSG = "Error occured while generatin
const string CANONICAL_QUERY_STRING_GENERATION_ERROR_MSG = "Error occured while generating canonical query string.";
const string XML_EXTRACTION_ERROR_MSG = "Error occurred while accessing the XML payload from the http response.";
const string BINARY_CONTENT_EXTRACTION_ERROR_MSG = "Error occured while accessing binary content from the http response";
const EXPIRATION_TIME_ERROR_MSG = "Invalid expiration time. Expiration time should be a positive integer.";
const EMPTY_OBJECT_NAME_ERROR_MSG = "Invalid object name. Object name should not be empty.";
const EMPTY_BUCKET_NAME_ERROR_MSG = "Invalid bucket name. Bucket name should not be empty.";

# The action to be carried out on the object.
public enum ObjectAction {
# Create a new object
CREATE,
# Retrieve an existing object
RETRIEVE
};
50 changes: 49 additions & 1 deletion ballerina/tests/test.bal
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
// under the License.
//

import ballerina/http;
import ballerina/io;
import ballerina/log;
import ballerina/test;
import ballerina/os;
import ballerina/test;

configurable string testBucketName = os:getEnv("BUCKET_NAME");
configurable string accessKeyId = os:getEnv("ACCESS_KEY_ID");
Expand Down Expand Up @@ -84,6 +85,53 @@ function testCreateObject() {
}
}

@test:Config {
dependsOn: [testGetObject]
}
function testCreatePresignedUrlGet() returns error? {
log:printInfo("amazonS3Client->createPresignedUrl() RETRIEVE");
Client amazonS3Client = check new (amazonS3Config);
string url = check amazonS3Client->createPresignedUrl(testBucketName, fileName, RETRIEVE, 3600);
http:Client httpClient = check new (url);
http:Response httpResponse = check httpClient->get(EMPTY_STRING);
test:assertEquals(httpResponse.statusCode, 200, "Failed to create presigned URL");
}

@test:Config {
dependsOn: [testGetObject]
}
function testCreatePresignedUrlPut() returns error? {
log:printInfo("amazonS3Client->createPresignedUrl() CREATE");
Client amazonS3Client = check new (amazonS3Config);
string url = check amazonS3Client->createPresignedUrl(testBucketName, fileName, CREATE, 3600);
http:Client httpClient = check new (url);
http:Response httpResponse = check httpClient->put(EMPTY_STRING, content);
test:assertEquals(httpResponse.statusCode, 200, "Failed to create presigned URL");
}

@test:Config {
dependsOn: [testGetObject]
}
function testCreatePresignedUrlWithInvalidObjectName() returns error? {
log:printInfo("amazonS3Client->createPresignedUrl() with invalid object name");
Client amazonS3Client = check new (amazonS3Config);
string|error url = amazonS3Client->createPresignedUrl(testBucketName, EMPTY_STRING, RETRIEVE, 3600);
test:assertTrue(url is error, msg = "Expected an error but got a URL");
test:assertEquals((<error>url).message(), EMPTY_OBJECT_NAME_ERROR_MSG);
}

@test:Config {
dependsOn: [testGetObject]
}

function testCreatePresignedUrlWithInvalidBucketName() returns error? {
log:printInfo("amazonS3Client->createPresignedUrl() with invalid bucket name");
Client amazonS3Client = check new (amazonS3Config);
string|error url = amazonS3Client->createPresignedUrl(EMPTY_STRING, fileName, RETRIEVE, 3600);
test:assertTrue(url is error, msg = "Expected an error but got a URL");
test:assertEquals((<error>url).message(), EMPTY_BUCKET_NAME_ERROR_MSG);
}

@test:Config {
dependsOn: [testCreateBucket]
}
Expand Down
41 changes: 32 additions & 9 deletions ballerina/utils.bal
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,20 @@ isolated function generateCanonicalHeaders(map<string> headers, http:Request? re
return [canonicalHeaders, signedHeaders];
}

# Function to generate signing key.
#
# + secretAccessKey - Value of the secret key
# + shortDateStr - shortDateStr Parameter Description
# + region - Endpoint region
# + return - Signing key
isolated function generateSigningKey(string secretAccessKey, string shortDateStr, string region) returns byte[]|error {
string signValue = AWS4 + secretAccessKey;
byte[] dateKey = check crypto:hmacSha256(shortDateStr.toBytes(), signValue.toBytes());
byte[] regionKey = check crypto:hmacSha256(region.toBytes(), dateKey);
byte[] serviceKey = check crypto:hmacSha256(SERVICE_NAME.toBytes(), regionKey);
return crypto:hmacSha256(TERMINATION_STRING.toBytes(), serviceKey);
}

# Funtion to construct authorization header string.
#
# + accessKeyId - Value of the access key.
Expand All @@ -198,21 +212,30 @@ isolated function generateCanonicalHeaders(map<string> headers, http:Request? re
# + stringToSign - stringToSign Parameter Description
# + return - Authorization header string value.
isolated function constructAuthSignature(string accessKeyId, string secretAccessKey, string shortDateStr, string region,
string signedHeaders, string stringToSign) returns string|error {
string signValue = AWS4 + secretAccessKey;
byte[] dateKey = check crypto:hmacSha256(shortDateStr.toBytes(), signValue.toBytes());
byte[] regionKey = check crypto:hmacSha256(region.toBytes(), dateKey);
byte[] serviceKey = check crypto:hmacSha256(SERVICE_NAME.toBytes(), regionKey);
byte[] signingKey = check crypto:hmacSha256(TERMINATION_STRING.toBytes(), serviceKey);

string encodedStr = array:toBase16(check crypto:hmacSha256(stringToSign.toBytes(), signingKey));
string signedHeaders, string stringToSign) returns string|error {
byte[] signingKey = check generateSigningKey(secretAccessKey, shortDateStr, region);
string encodedStr = array:toBase16(check crypto:hmacSha256(stringToSign.toBytes(), signingKey));
string credential = string `${accessKeyId}/${shortDateStr}/${region}/${SERVICE_NAME}/${TERMINATION_STRING}`;
string authHeader = string `${AWS4_HMAC_SHA256} ${CREDENTIAL}=${credential},${SIGNED_HEADER}=${signedHeaders}`;
authHeader = string `${authHeader},${SIGNATURE}=${encodedStr.toLowerAscii()}`;

return authHeader;
}

# Function to construct signature for presigned URLs.
#
# + accessKeyId - Value of the access key
# + secretAccessKey - Value of the secret key
# + shortDateStr - The string representation of the current date in 'yyyyMMdd' format
# + region - Endpoint region
# + stringToSign - String including information such as the HTTP method, resource path, query parameters, and headers
# + return - Signature used for authentication
isolated function constructPresignedUrlSignature(string accessKeyId, string secretAccessKey, string shortDateStr,
string region, string stringToSign) returns string|error {
byte[] signingKey = check generateSigningKey(secretAccessKey, shortDateStr, region);
string encodedStr = array:toBase16(check crypto:hmacSha256(stringToSign.toBytes(), signingKey));
return encodedStr.toLowerAscii();
}

# Function to populate createObject optional headers.
#
# + requestHeaders - Request headers map.
Expand Down

0 comments on commit 053b46a

Please sign in to comment.