Repository: googleapis/google-auth-library-python

Issue:
Add a five minute buffer to all expiration checks



Discussion:
Isn't this already built in? From the [`oauth2client` codebase][1]:

```python
CLOCK_SKEW_SECS = 300  # 5 minutes in seconds
```

[1]: https://github.com/google/oauth2client/blob/c055e7f7f5b7f7f9fe8ba12bbf7e297fccc2b59b/oauth2client/crypt.py#L29
I left it out when I wrote this library. :)
But that's built into the spec, i.e. it wasn't just a quirk of `oauth2client`.
Which spec? I don't remember reading about that. I might have overlooked it.

There's also an internal debating on-going about what the interval should be. :)
Not sure which spec? Tried googling just now and gave up.
*insert shruggie*
The [JWT profile / ID token spec][2] and the [JWT spec][1] mention "skew" but they don't give specific values. For example:

>  The JWT MUST contain an "exp" (expiration time) claim that limits the time window during which the JWT can be used.  The authorization server MUST reject any JWT with an expiration time that has passed, subject to allowable clock skew between systems.  Note that the authorization server may reject JWTs with an "exp" claim value that is unreasonably far in the future.

[1]: https://tools.ietf.org/html/rfc7519
[2]: https://tools.ietf.org/html/rfc7523

Resolving patch:
diff --git a/google/auth/_helpers.py b/google/auth/_helpers.py
--- a/google/auth/_helpers.py
+++ b/google/auth/_helpers.py
@@ -22,6 +22,10 @@
 from six.moves import urllib
 
 
+CLOCK_SKEW_SECS = 300  # 5 minutes in seconds
+CLOCK_SKEW = datetime.timedelta(seconds=CLOCK_SKEW_SECS)
+
+
 def copy_docstring(source_class):
     """Decorator that copies a method's docstring from another class.
 
diff --git a/google/auth/credentials.py b/google/auth/credentials.py
--- a/google/auth/credentials.py
+++ b/google/auth/credentials.py
@@ -56,8 +56,10 @@ def expired(self):
         Note that credentials can be invalid but not expired becaue Credentials
         with :attr:`expiry` set to None is considered to never expire.
         """
-        now = _helpers.utcnow()
-        return self.expiry is not None and self.expiry <= now
+        # Err on the side of reporting expiration early so that we avoid
+        # the 403-refresh-retry loop.
+        adjusted_now = _helpers.utcnow() - _helpers.CLOCK_SKEW
+        return self.expiry is not None and self.expiry <= adjusted_now
 
     @property
     def valid(self):
diff --git a/google/auth/jwt.py b/google/auth/jwt.py
--- a/google/auth/jwt.py
+++ b/google/auth/jwt.py
@@ -52,8 +52,7 @@
 import google.auth.credentials
 
 
-_DEFAULT_TOKEN_LIFETIME_SECS = 3600  # 1 hour in sections
-_CLOCK_SKEW_SECS = 300  # 5 minutes in seconds
+_DEFAULT_TOKEN_LIFETIME_SECS = 3600  # 1 hour in seconds
 
 
 def encode(signer, payload, header=None, key_id=None):
@@ -161,21 +160,25 @@ def _verify_iat_and_exp(payload):
     """
     now = _helpers.datetime_to_secs(_helpers.utcnow())
 
-    # Make sure the iat and exp claims are present
+    # Make sure the iat and exp claims are present.
     for key in ('iat', 'exp'):
         if key not in payload:
             raise ValueError(
                 'Token does not contain required claim {}'.format(key))
 
-    # Make sure the token wasn't issued in the future
+    # Make sure the token wasn't issued in the future.
     iat = payload['iat']
-    earliest = iat - _CLOCK_SKEW_SECS
+    # Err on the side of accepting a token that is slightly early to account
+    # for clock skew.
+    earliest = iat - _helpers.CLOCK_SKEW_SECS
     if now < earliest:
         raise ValueError('Token used too early, {} < {}'.format(now, iat))
 
-    # Make sure the token wasn't issue in the past
+    # Make sure the token wasn't issued in the past.
     exp = payload['exp']
-    latest = exp + _CLOCK_SKEW_SECS
+    # Err on the side of accepting a token that is slightly out of date
+    # to account for clow skew.
+    latest = exp + _helpers.CLOCK_SKEW_SECS
     if latest < now:
         raise ValueError('Token expired, {} < {}'.format(latest, now))
 


Test patch:
diff --git a/tests/compute_engine/test_credentials.py b/tests/compute_engine/test_credentials.py
--- a/tests/compute_engine/test_credentials.py
+++ b/tests/compute_engine/test_credentials.py
@@ -17,6 +17,7 @@
 import mock
 import pytest
 
+from google.auth import _helpers
 from google.auth import exceptions
 from google.auth.compute_engine import credentials
 
@@ -38,7 +39,8 @@ def test_default_state(self):
         assert self.credentials.service_account_email == 'default'
 
     @mock.patch(
-        'google.auth._helpers.utcnow', return_value=datetime.datetime.min)
+        'google.auth._helpers.utcnow',
+        return_value=datetime.datetime.min + _helpers.CLOCK_SKEW)
     @mock.patch('google.auth.compute_engine._metadata.get')
     def test_refresh_success(self, get_mock, now_mock):
         get_mock.side_effect = [{
@@ -57,7 +59,7 @@ def test_refresh_success(self, get_mock, now_mock):
         # Check that the credentials have the token and proper expiration
         assert self.credentials.token == 'token'
         assert self.credentials.expiry == (
-            datetime.datetime.min + datetime.timedelta(seconds=500))
+            now_mock() + datetime.timedelta(seconds=500))
 
         # Check the credential info
         assert (self.credentials.service_account_email ==
diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py
--- a/tests/oauth2/test_credentials.py
+++ b/tests/oauth2/test_credentials.py
@@ -53,7 +53,8 @@ def test_create_scoped(self):
 
     @mock.patch('google.oauth2._client.refresh_grant', autospec=True)
     @mock.patch(
-        'google.auth._helpers.utcnow', return_value=datetime.datetime.min)
+        'google.auth._helpers.utcnow',
+        return_value=datetime.datetime.min + _helpers.CLOCK_SKEW)
     def test_refresh_success(self, now_mock, refresh_grant_mock):
         token = 'token'
         expiry = _helpers.utcnow() + datetime.timedelta(seconds=500)
diff --git a/tests/test_app_engine.py b/tests/test_app_engine.py
--- a/tests/test_app_engine.py
+++ b/tests/test_app_engine.py
@@ -17,6 +17,7 @@
 import mock
 import pytest
 
+from google.auth import _helpers
 from google.auth import app_engine
 
 
@@ -111,7 +112,7 @@ def test_service_account_email_explicit(self, app_identity_mock):
 
     @mock.patch(
         'google.auth._helpers.utcnow',
-        return_value=datetime.datetime.min)
+        return_value=datetime.datetime.min + _helpers.CLOCK_SKEW)
     def test_refresh(self, now_mock, app_identity_mock):
         token = 'token'
         ttl = 100
@@ -124,7 +125,7 @@ def test_refresh(self, now_mock, app_identity_mock):
             credentials.scopes, credentials._service_account_id)
         assert credentials.token == token
         assert credentials.expiry == (
-            datetime.datetime.min + datetime.timedelta(seconds=ttl))
+            now_mock() + datetime.timedelta(seconds=ttl))
         assert credentials.valid
         assert not credentials.expired
 
diff --git a/tests/test_credentials.py b/tests/test_credentials.py
--- a/tests/test_credentials.py
+++ b/tests/test_credentials.py
@@ -14,6 +14,7 @@
 
 import datetime
 
+from google.auth import _helpers
 from google.auth import credentials
 
 
@@ -37,8 +38,21 @@ def test_expired_and_valid():
     assert credentials.valid
     assert not credentials.expired
 
+    # Set the expiration in the past, but because of clock skew accomodation
+    # the credentials should still be valid.
     credentials.expiry = (
-        datetime.datetime.utcnow() - datetime.timedelta(seconds=60))
+        datetime.datetime.utcnow() -
+        datetime.timedelta(seconds=1))
+
+    assert credentials.valid
+    assert not credentials.expired
+
+    # Set the credentials far enough in the past to exceed the clock skew
+    # accomodation. They should now be expired.
+    credentials.expiry = (
+        datetime.datetime.utcnow() -
+        _helpers.CLOCK_SKEW -
+        datetime.timedelta(seconds=1))
 
     assert not credentials.valid
     assert credentials.expired