Fix - #1685 - pre-signed-url expiry after given timestamp,s3 object e…
diff --git a/localstack/services/s3/s3_listener.py b/localstack/services/s3/s3_listener.py
index be212625e439c..2a86c7ef9de85 100644
--- a/localstack/services/s3/s3_listener.py
+++ b/localstack/services/s3/s3_listener.py
@@ -2,6 +2,8 @@
 import re
 import logging
 import json
+import time
+from pytz import timezone
 import uuid
 import base64
 import codecs
@@ -47,6 +49,9 @@
 # maps bucket name to object lock settings
 OBJECT_LOCK_CONFIGS = {}
 
+# map to store the s3 expiry dates
+OBJECT_EXPIRY = {}
+
 # set up logger
 LOGGER = logging.getLogger(__name__)
 
@@ -320,6 +325,30 @@ def add_accept_range_header(response):
         response.headers['accept-ranges'] = 'bytes'
 
 
+def is_object_expired(path):
+    object_expiry = get_object_expiry(path)
+    if not object_expiry:
+        return False
+    if dateutil.parser.parse(object_expiry) > \
+            datetime.datetime.now(timezone(dateutil.parser.parse(object_expiry).tzname())):
+        return False
+    return True
+
+
+def set_object_expiry(path, headers):
+    OBJECT_EXPIRY[path] = headers.get('expires')
+
+
+def get_object_expiry(path):
+    return OBJECT_EXPIRY.get(path)
+
+
+def is_url_already_expired(expiry_timestamp):
+    if int(expiry_timestamp) < int(time.time()):
+        return True
+    return False
+
+
 def add_reponse_metadata_headers(response):
     if response.headers.get('content-language') is None:
         response.headers['content-language'] = 'en-US'
@@ -633,6 +662,24 @@ def error_response(message, code, status_code=400):
     return requests_response(content, status_code=status_code, headers=headers)
 
 
+def no_such_key_error(resource, requestId=None, status_code=400):
+    result = {'Error': {'Code': 'NoSuchKey',
+            'Message': 'The resource you requested does not exist',
+            'Resource': resource, 'RequestId': requestId}}
+    content = xmltodict.unparse(result)
+    headers = {'content-type': 'application/xml'}
+    return requests_response(content, status_code=status_code, headers=headers)
+
+
+def token_expired_error(resource, requestId=None, status_code=400):
+    result = {'Error': {'Code': 'ExpiredToken',
+            'Message': 'The provided token has expired.',
+            'Resource': resource, 'RequestId': requestId}}
+    content = xmltodict.unparse(result)
+    headers = {'content-type': 'application/xml'}
+    return requests_response(content, status_code=status_code, headers=headers)
+
+
 def expand_redirect_url(starting_url, key, bucket):
     """ Add key and bucket parameters to starting URL query string. """
     parsed = urlparse.urlparse(starting_url)
@@ -890,6 +937,11 @@ def forward_request(self, method, path, data, headers):
             response = handle_notification_request(bucket, method, data)
             return response
 
+        # if the Expires key in the url is already expired then return error
+        if method == 'GET' and 'Expires' in query_map:
+            if is_url_already_expired(query_map.get('Expires')[0]):
+                return token_expired_error(path, headers.get('x-amz-request-id'), 400)
+
         if query == 'cors' or 'cors' in query_map:
             if method == 'GET':
                 return get_cors(bucket)
@@ -1046,6 +1098,9 @@ def return_response(self, method, path, data, headers, response):
             fix_etag_for_multipart(data, headers, response)
             append_aws_request_troubleshooting_headers(response)
 
+            if method == 'PUT':
+                set_object_expiry(path, headers)
+
             # Remove body from PUT response on presigned URL
             # https://github.com/localstack/localstack/issues/1317
             if method == 'PUT' and ('X-Amz-Security-Token=' in path or
@@ -1064,6 +1119,9 @@ def return_response(self, method, path, data, headers, response):
             if method == 'GET':
                 add_accept_range_header(response)
                 add_reponse_metadata_headers(response)
+                if is_object_expired(path):
+                    return no_such_key_error(path, headers.get('x-amz-request-id'), 400)
+
                 query_map = urlparse.parse_qs(parsed.query, keep_blank_values=True)
                 for param_name, header_name in ALLOWED_HEADER_OVERRIDES.items():
                     if param_name in query_map:
diff --git a/tests/integration/test_s3.py b/tests/integration/test_s3.py
index 204f0f5610081..5447ffe19e855 100644
--- a/tests/integration/test_s3.py
+++ b/tests/integration/test_s3.py
@@ -1,12 +1,16 @@
+import datetime
 import io
 import os
 import ssl
 import gzip
 import json
+import time
 import uuid
 import unittest
 import requests
 from io import BytesIO
+
+from pytz import timezone
 from six.moves.urllib.request import Request, urlopen
 from localstack import config
 from localstack.utils.aws import aws_stack
@@ -254,6 +258,65 @@ def test_s3_put_metadata_underscores(self):
         # clean up
         self._delete_bucket(bucket_name, [object_key])
 
+    def test_s3_object_expiry(self):
+        # handle s3 object expiry
+        # https://github.com/localstack/localstack/issues/1685
+        bucket_name = 'test-%s' % short_uid()
+        self.s3_client.create_bucket(Bucket=bucket_name)
+
+        # put object
+        object_key = 'key-with-metadata'
+        metadata = {'test_meta_1': 'foo', '__meta_2': 'bar'}
+        self.s3_client.put_object(Bucket=bucket_name, Key=object_key, Metadata=metadata, Body='foo',
+                                  Expires=datetime.datetime.now(timezone('GMT')) - datetime.timedelta(hours=1))
+        # try to fetch an object which is already expired
+        self.assertRaises(Exception, self.s3_client.get_object, Bucket=bucket_name, Key=object_key.lower())
+
+        self.s3_client.put_object(Bucket=bucket_name, Key=object_key, Metadata=metadata, Body='foo',
+                                  Expires=datetime.datetime.now(timezone('GMT')) + datetime.timedelta(hours=1))
+
+        # try to fetch has not been expired yet.
+        resp = self.s3_client.get_object(Bucket=bucket_name, Key=object_key)
+
+        self.assertIn('Expires', resp)
+
+        # clean up
+        self._delete_bucket(bucket_name, [object_key])
+
+    def test_s3_predesigned_url_expired(self):
+
+        bucket_name = 'test-bucket-%s' % short_uid()
+        self.s3_client.create_bucket(Bucket=bucket_name)
+
+        # put object and CORS configuration
+        object_key = 'key-by-hostname'
+        self.s3_client.put_object(Bucket=bucket_name, Key=object_key, Body='something')
+
+        # get object and assert headers
+        url = self.s3_client.generate_presigned_url(
+            'get_object', Params={'Bucket': bucket_name, 'Key': object_key}, ExpiresIn=2
+        )
+        # retrieving it before expiry
+        resp = requests.get(url, verify=False)
+        self.assertEqual(resp.status_code, 200)
+        self.assertEqual(resp.content, 'something')
+
+        # waiting for the url to expire
+        time.sleep(3)
+        resp = requests.get(url, verify=False)
+        self.assertEqual(resp.status_code, 400)
+
+        url = self.s3_client.generate_presigned_url(
+            'get_object', Params={'Bucket': bucket_name, 'Key': object_key}, ExpiresIn=120
+        )
+
+        resp = requests.get(url, verify=False)
+        self.assertEqual(resp.status_code, 200)
+        self.assertEqual(resp.content, 'something')
+
+        # clean up
+        self._delete_bucket(bucket_name, [object_key])
+
     def test_range_header_body_length(self):
         # Test for https://github.com/localstack/localstack/issues/1952
 

https://github.com/localstack/localstack/issues/1685

pre-signed-url expiry after given timestamp,s3 object expiry after timestamp

