Repository: boto/botocore
Pull request #1187 (2017-05-01 17:22:01)

Support env var credential expiry
This adds support for environment credential expiration via the `AWS_CREDENTIAL_EXPIRATION` environment variable.

codecov-io: # [Codecov](https://codecov.io/gh/boto/botocore/pull/1187?src=pr&el=h1) Report
> Merging [#1187](https://codecov.io/gh/boto/botocore/pull/1187?src=pr&el=desc) into [develop](https://codecov.io/gh/boto/botocore/commit/3ca9e033eb5ac2e685a23bf7e4dd13e58e41bc5f?src=pr&el=desc) will **decrease** coverage by `<.01%`.
> The diff coverage is `96.87%`.

[![Impacted file tree graph](https://codecov.io/gh/boto/botocore/pull/1187/graphs/tree.svg?token=QOLXw6XSza&src=pr&height=150&width=650)](https://codecov.io/gh/boto/botocore/pull/1187?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           develop   #1187      +/-   ##
==========================================
- Coverage       98%     98%   -0.01%     
==========================================
  Files           45      45              
  Lines         7277    7302      +25     
==========================================
+ Hits          7132    7156      +24     
- Misses         145     146       +1
```


| [Impacted Files](https://codecov.io/gh/boto/botocore/pull/1187?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [botocore/credentials.py](https://codecov.io/gh/boto/botocore/pull/1187?src=pr&el=tree#diff-Ym90b2NvcmUvY3JlZGVudGlhbHMucHk=) | `97.71% <96.87%> (+0.11%)` | :arrow_up: |
| [botocore/awsrequest.py](https://codecov.io/gh/boto/botocore/pull/1187?src=pr&el=tree#diff-Ym90b2NvcmUvYXdzcmVxdWVzdC5weQ==) | `98.18% <0%> (-0.46%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/boto/botocore/pull/1187?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/boto/botocore/pull/1187?src=pr&el=footer). Last update [3ca9e03...ad12b3a](https://codecov.io/gh/boto/botocore/pull/1187?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

JordonPhillips: RIP codecov
JordonPhillips: Updated

Patch:
diff --git a/.changes/next-release/enhancement-Credentials-25943.json b/.changes/next-release/enhancement-Credentials-25943.json
new file mode 100644
index 000000000..94ab82eea
--- /dev/null
+++ b/.changes/next-release/enhancement-Credentials-25943.json
@@ -0,0 +1,5 @@
+{
+  "category": "Credentials",
+  "description": "Add support for environment variable credential expiration.",
+  "type": "enhancement"
+}
diff --git a/botocore/credentials.py b/botocore/credentials.py
index 3863f7cc2..fb0198773 100644
--- a/botocore/credentials.py
+++ b/botocore/credentials.py
@@ -508,6 +508,7 @@ class EnvProvider(CredentialProvider):
     # The token can come from either of these env var.
     # AWS_SESSION_TOKEN is what other AWS SDKs have standardized on.
     TOKENS = ['AWS_SECURITY_TOKEN', 'AWS_SESSION_TOKEN']
+    EXPIRY_TIME = 'AWS_CREDENTIAL_EXPIRATION'
 
     def __init__(self, environ=None, mapping=None):
         """
@@ -533,6 +534,7 @@ class EnvProvider(CredentialProvider):
             var_mapping['access_key'] = self.ACCESS_KEY
             var_mapping['secret_key'] = self.SECRET_KEY
             var_mapping['token'] = self.TOKENS
+            var_mapping['expiry_time'] = self.EXPIRY_TIME
         else:
             var_mapping['access_key'] = mapping.get(
                 'access_key', self.ACCESS_KEY)
@@ -542,6 +544,8 @@ class EnvProvider(CredentialProvider):
                 'token', self.TOKENS)
             if not isinstance(var_mapping['token'], list):
                 var_mapping['token'] = [var_mapping['token']]
+            var_mapping['expiry_time'] = mapping.get(
+                'expiry_time', self.EXPIRY_TIME)
         return var_mapping
 
     def load(self):
@@ -550,19 +554,61 @@ class EnvProvider(CredentialProvider):
         """
         if self._mapping['access_key'] in self.environ:
             logger.info('Found credentials in environment variables.')
-            access_key, secret_key = self._extract_creds_from_mapping(
-                self.environ, self._mapping['access_key'],
-                self._mapping['secret_key'])
-            token = self._get_session_token()
-            return Credentials(access_key, secret_key, token,
-                               method=self.METHOD)
+            fetcher = self._create_credentials_fetcher()
+            credentials = fetcher(require_expiry=False)
+
+            expiry_time = credentials['expiry_time']
+            if expiry_time is not None:
+                expiry_time = parse(expiry_time)
+                return RefreshableCredentials(
+                    credentials['access_key'], credentials['secret_key'],
+                    credentials['token'], expiry_time,
+                    refresh_using=fetcher, method=self.METHOD
+                )
+
+            return Credentials(
+                credentials['access_key'], credentials['secret_key'],
+                credentials['token'], method=self.METHOD
+            )
         else:
             return None
 
-    def _get_session_token(self):
-        for token_envvar in self._mapping['token']:
-            if token_envvar in self.environ:
-                return self.environ[token_envvar]
+    def _create_credentials_fetcher(self):
+        mapping = self._mapping
+        method = self.METHOD
+        environ = self.environ
+
+        def fetch_credentials(require_expiry=True):
+            credentials = {}
+
+            access_key = environ.get(mapping['access_key'])
+            if access_key is None:
+                raise PartialCredentialsError(
+                    provider=method, cred_var=mapping['access_key'])
+            credentials['access_key'] = access_key
+
+            secret_key = environ.get(mapping['secret_key'])
+            if secret_key is None:
+                raise PartialCredentialsError(
+                    provider=method, cred_var=mapping['secret_key'])
+            credentials['secret_key'] = secret_key
+
+            token = None
+            for token_env_var in mapping['token']:
+                if token_env_var in environ:
+                    token = environ[token_env_var]
+                    break
+            credentials['token'] = token
+
+            expiry_time = environ.get(mapping['expiry_time'])
+            if require_expiry and expiry_time is None:
+                raise PartialCredentialsError(
+                    provider=method, cred_var=mapping['expiry_time'])
+            credentials['expiry_time'] = expiry_time
+
+            return credentials
+
+        return fetch_credentials
 
 
 class OriginalEC2Provider(CredentialProvider):


Test patch:
diff --git a/tests/unit/test_credentials.py b/tests/unit/test_credentials.py
index 811f8c471..86d391654 100644
--- a/tests/unit/test_credentials.py
+++ b/tests/unit/test_credentials.py
@@ -211,6 +211,29 @@ class TestEnvVar(BaseEnvVar):
         self.assertEqual(creds.secret_key, 'bar')
         self.assertEqual(creds.token, 'baz')
 
+    def test_can_override_expiry_env_var_mapping(self):
+        expiry_time = datetime.now(tzlocal()) - timedelta(hours=1)
+        environ = {
+            'AWS_ACCESS_KEY_ID': 'foo',
+            'AWS_SECRET_ACCESS_KEY': 'bar',
+            'AWS_SESSION_TOKEN': 'baz',
+            'FOO_EXPIRY': expiry_time.isoformat(),
+        }
+        provider = credentials.EnvProvider(
+            environ, {'expiry_time': 'FOO_EXPIRY'}
+        )
+        creds = provider.load()
+
+        # Since the credentials are expired, we'll trigger a refresh whenever
+        # we try to access them. Since the environment credentials are still
+        # expired, this will raise an error.
+        error_message = (
+            "Credentials were refreshed, but the refreshed credentials are "
+            "still expired."
+        )
+        with self.assertRaisesRegexp(RuntimeError, error_message):
+            creds.get_frozen_credentials()
+
     def test_partial_creds_is_an_error(self):
         # If the user provides an access key, they must also
         # provide a secret key.  Not doing so will generate an
@@ -223,6 +246,146 @@ class TestEnvVar(BaseEnvVar):
         with self.assertRaises(botocore.exceptions.PartialCredentialsError):
             provider.load()
 
+    def test_missing_access_key_id_raises_error(self):
+        expiry_time = datetime.now(tzlocal()) - timedelta(hours=1)
+        environ = {
+            'AWS_ACCESS_KEY_ID': 'foo',
+            'AWS_SECRET_ACCESS_KEY': 'bar',
+            'AWS_CREDENTIAL_EXPIRATION': expiry_time.isoformat(),
+        }
+        provider = credentials.EnvProvider(environ)
+        creds = provider.load()
+
+        del environ['AWS_ACCESS_KEY_ID']
+
+        # Since the credentials are expired, we'll trigger a refresh
+        # whenever we try to access them. At that refresh time, the relevant
+        # environment variables are incomplete, so an error will be raised.
+        with self.assertRaises(botocore.exceptions.PartialCredentialsError):
+            creds.get_frozen_credentials()
+
+    def test_credentials_refresh(self):
+        # First initialize the credentials with an expired credential set.
+        expiry_time = datetime.now(tzlocal()) - timedelta(hours=1)
+        environ = {
+            'AWS_ACCESS_KEY_ID': 'foo',
+            'AWS_SECRET_ACCESS_KEY': 'bar',
+            'AWS_SESSION_TOKEN': 'baz',
+            'AWS_CREDENTIAL_EXPIRATION': expiry_time.isoformat(),
+        }
+        provider = credentials.EnvProvider(environ)
+        creds = provider.load()
+        self.assertIsInstance(creds, credentials.RefreshableCredentials)
+
+        # Since the credentials are expired, we'll trigger a refresh whenever
+        # we try to access them. But at this point the environment hasn't been
+        # updated, so when it refreshes it will trigger an exception because
+        # the new creds are still expired.
+        error_message = (
+            "Credentials were refreshed, but the refreshed credentials are "
+            "still expired."
+        )
+        with self.assertRaisesRegexp(RuntimeError, error_message):
+            creds.get_frozen_credentials()
+
+        # Now we update the environment with non-expired credentials,
+        # so when we access the creds it will refresh and grab the new ones.
+        expiry_time = datetime.now(tzlocal()) + timedelta(hours=1)
+        environ.update({
+            'AWS_ACCESS_KEY_ID': 'bin',
+            'AWS_SECRET_ACCESS_KEY': 'bam',
+            'AWS_SESSION_TOKEN': 'biz',
+            'AWS_CREDENTIAL_EXPIRATION': expiry_time.isoformat(),
+        })
+
+        frozen = creds.get_frozen_credentials()
+        self.assertEqual(frozen.access_key, 'bin')
+        self.assertEqual(frozen.secret_key, 'bam')
+        self.assertEqual(frozen.token, 'biz')
+
+    def test_credentials_only_refresh_when_needed(self):
+        expiry_time = datetime.now(tzlocal()) + timedelta(hours=2)
+        environ = {
+            'AWS_ACCESS_KEY_ID': 'foo',
+            'AWS_SECRET_ACCESS_KEY': 'bar',
+            'AWS_SESSION_TOKEN': 'baz',
+            'AWS_CREDENTIAL_EXPIRATION': expiry_time.isoformat(),
+        }
+        provider = credentials.EnvProvider(environ)
+
+        # Perform the initial credential load
+        creds = provider.load()
+
+        # Now that the initial load has been performed, we go ahead and
+        # change the environment. If the credentials were expired,
+        # they would immediately refresh upon access and we'd get the new
+        # ones. Since they've got plenty of time, they shouldn't refresh.
+        expiry_time = datetime.now(tzlocal()) + timedelta(hours=3)
+        environ.update({
+            'AWS_ACCESS_KEY_ID': 'bin',
+            'AWS_SECRET_ACCESS_KEY': 'bam',
+            'AWS_SESSION_TOKEN': 'biz',
+            'AWS_CREDENTIAL_EXPIRATION': expiry_time.isoformat(),
+        })
+
+        frozen = creds.get_frozen_credentials()
+        self.assertEqual(frozen.access_key, 'foo')
+        self.assertEqual(frozen.secret_key, 'bar')
+        self.assertEqual(frozen.token, 'baz')
+
+    def test_credentials_not_refreshable_if_no_expiry_present(self):
+        environ = {
+            'AWS_ACCESS_KEY_ID': 'foo',
+            'AWS_SECRET_ACCESS_KEY': 'bar',
+            'AWS_SESSION_TOKEN': 'baz',
+        }
+        provider = credentials.EnvProvider(environ)
+        creds = provider.load()
+        self.assertNotIsInstance(creds, credentials.RefreshableCredentials)
+        self.assertIsInstance(creds, credentials.Credentials)
+
+    def test_credentials_do_not_become_refreshable(self):
+        environ = {
+            'AWS_ACCESS_KEY_ID': 'foo',
+            'AWS_SECRET_ACCESS_KEY': 'bar',
+            'AWS_SESSION_TOKEN': 'baz',
+        }
+        provider = credentials.EnvProvider(environ)
+        creds = provider.load()
+        frozen = creds.get_frozen_credentials()
+        self.assertEqual(frozen.access_key, 'foo')
+        self.assertEqual(frozen.secret_key, 'bar')
+        self.assertEqual(frozen.token, 'baz')
+
+        expiry_time = datetime.now(tzlocal()) - timedelta(hours=1)
+        environ.update({
+            'AWS_ACCESS_KEY_ID': 'bin',
+            'AWS_SECRET_ACCESS_KEY': 'bam',
+            'AWS_SESSION_TOKEN': 'biz',
+            'AWS_CREDENTIAL_EXPIRATION': expiry_time.isoformat(),
+        })
+
+        frozen = creds.get_frozen_credentials()
+        self.assertEqual(frozen.access_key, 'foo')
+        self.assertEqual(frozen.secret_key, 'bar')
+        self.assertEqual(frozen.token, 'baz')
+        self.assertNotIsInstance(creds, credentials.RefreshableCredentials)
+
+    def test_credentials_throw_error_if_expiry_goes_away(self):
+        expiry_time = datetime.now(tzlocal()) - timedelta(hours=1)
+        environ = {
+            'AWS_ACCESS_KEY_ID': 'foo',
+            'AWS_SECRET_ACCESS_KEY': 'bar',
+            'AWS_CREDENTIAL_EXPIRATION': expiry_time.isoformat(),
+        }
+        provider = credentials.EnvProvider(environ)
+        creds = provider.load()
+
+        del environ['AWS_CREDENTIAL_EXPIRATION']
+
+        with self.assertRaises(credentials.PartialCredentialsError):
+            creds.get_frozen_credentials()
+
 
 class TestSharedCredentialsProvider(BaseEnvVar):
     def setUp(self):