Repository: jazzband/django-oauth-toolkit

Issue:
Wrong timezone is applied to expiry token
**Describe the bug**
The `_get_token_from_authentication_server` interprets the expire date wrong. The token is read from `content['exp']` which is defined as NumericDate which in turn is defined as UNIX **UTC** Timestamp (seconds since epoch). The `exp` is converted to a date using `utcfromtimestamp` followed by `make_aware` to turn it into a timezone aware date.  The latter uses the timezone as chosen for you project, while it should use the UTC timezone. Now given a timezone +2 and an access token which expires after an hour, the access token will be invalid because it's in the past.

**To Reproduce**
1. Setup identity provider that serves access token with ACCESS_TOKEN_EXPIRE_SECONDS set to 3600
2. Setup a resource  server with timezone set to TIME_ZONE = "Europe/Amsterdam" (+2)
3. Request Access Token and try to access Resource Server
4. Access Token is always expired

**Expected behavior**
Access token is invalid

**Version**
django-oauth-toolkit==2.3.0

- [x] I have tested with the latest published release and it's still a problem.
- [x] I have tested with the master branch and it's still a problem.

**Additional context**
It's rather easy to fix changing:

```
- expires = make_aware(expires) if settings.USE_TZ else expires`
+ expires = make_aware(expires, timezone=pytz.UTC) if settings.USE_TZ else expires`
```

For backwards compatibility it would be better to has a setting which allows you to changes the timezone specifically for the expire token.



Discussion:

Resolving patch:
diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py
--- a/oauth2_provider/oauth2_validators.py
+++ b/oauth2_provider/oauth2_validators.py
@@ -38,6 +38,7 @@
 )
 from .scopes import get_scopes_backend
 from .settings import oauth2_settings
+from .utils import get_timezone
 
 
 log = logging.getLogger("oauth2_provider")
@@ -400,7 +401,11 @@ def _get_token_from_authentication_server(
                 expires = max_caching_time
 
             scope = content.get("scope", "")
-            expires = make_aware(expires) if settings.USE_TZ else expires
+
+            if settings.USE_TZ:
+                expires = make_aware(
+                    expires, timezone=get_timezone(oauth2_settings.AUTHENTICATION_SERVER_EXP_TIME_ZONE)
+                )
 
             access_token, _created = AccessToken.objects.update_or_create(
                 token=token,
diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py
--- a/oauth2_provider/settings.py
+++ b/oauth2_provider/settings.py
@@ -102,6 +102,8 @@
     "RESOURCE_SERVER_AUTH_TOKEN": None,
     "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None,
     "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000,
+    # Authentication Server Exp Timezone: the time zone use dby Auth Server for generate EXP
+    "AUTHENTICATION_SERVER_EXP_TIME_ZONE": "UTC",
     # Whether or not PKCE is required
     "PKCE_REQUIRED": True,
     # Whether to re-create OAuthlibCore on every request.
diff --git a/oauth2_provider/utils.py b/oauth2_provider/utils.py
--- a/oauth2_provider/utils.py
+++ b/oauth2_provider/utils.py
@@ -1,5 +1,6 @@
 import functools
 
+from django.conf import settings
 from jwcrypto import jwk
 
 
@@ -10,3 +11,24 @@ def jwk_from_pem(pem_string):
     Converting from PEM is expensive for large keys such as those using RSA.
     """
     return jwk.JWK.from_pem(pem_string.encode("utf-8"))
+
+
+# @functools.lru_cache
+def get_timezone(time_zone):
+    """
+    Return the default time zone as a tzinfo instance.
+
+    This is the time zone defined by settings.TIME_ZONE.
+    """
+    try:
+        import zoneinfo
+    except ImportError:
+        import pytz
+
+        return pytz.timezone(time_zone)
+    else:
+        if getattr(settings, "USE_DEPRECATED_PYTZ", False):
+            import pytz
+
+            return pytz.timezone(time_zone)
+        return zoneinfo.ZoneInfo(time_zone)


Test patch:
diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py
--- a/tests/test_introspection_auth.py
+++ b/tests/test_introspection_auth.py
@@ -29,7 +29,7 @@
 AccessToken = get_access_token_model()
 UserModel = get_user_model()
 
-exp = datetime.datetime.now() + datetime.timedelta(days=1)
+default_exp = datetime.datetime.now() + datetime.timedelta(days=1)
 
 
 class ScopeResourceView(ScopedProtectedResourceView):
@@ -42,19 +42,20 @@ def post(self, request, *args, **kwargs):
         return HttpResponse("This is a protected resource", 200)
 
 
+class MockResponse:
+    def __init__(self, json_data, status_code):
+        self.json_data = json_data
+        self.status_code = status_code
+
+    def json(self):
+        return self.json_data
+
+
 def mocked_requests_post(url, data, *args, **kwargs):
     """
     Mock the response from the authentication server
     """
 
-    class MockResponse:
-        def __init__(self, json_data, status_code):
-            self.json_data = json_data
-            self.status_code = status_code
-
-        def json(self):
-            return self.json_data
-
     if "token" in data and data["token"] and data["token"] != "12345678900":
         return MockResponse(
             {
@@ -62,7 +63,7 @@ def json(self):
                 "scope": "read write dolphin",
                 "client_id": "client_id_{}".format(data["token"]),
                 "username": "{}_user".format(data["token"]),
-                "exp": int(calendar.timegm(exp.timetuple())),
+                "exp": int(calendar.timegm(default_exp.timetuple())),
             },
             200,
         )
@@ -75,6 +76,21 @@ def json(self):
     )
 
 
+def mocked_introspect_request_short_living_token(url, data, *args, **kwargs):
+    exp = datetime.datetime.now() + datetime.timedelta(minutes=30)
+
+    return MockResponse(
+        {
+            "active": True,
+            "scope": "read write dolphin",
+            "client_id": "client_id_{}".format(data["token"]),
+            "username": "{}_user".format(data["token"]),
+            "exp": int(calendar.timegm(exp.timetuple())),
+        },
+        200,
+    )
+
+
 urlpatterns = [
     path("oauth2/", include("oauth2_provider.urls")),
     path("oauth2-test-resource/", ScopeResourceView.as_view()),
@@ -152,24 +168,76 @@ def test_get_token_from_authentication_server_existing_token(self, mock_get):
         self.assertEqual(token.user.username, "foo_user")
         self.assertEqual(token.scope, "read write dolphin")
 
-    @mock.patch("requests.post", side_effect=mocked_requests_post)
-    def test_get_token_from_authentication_server_expires_timezone(self, mock_get):
+    @mock.patch("requests.post", side_effect=mocked_introspect_request_short_living_token)
+    def test_get_token_from_authentication_server_expires_no_timezone(self, mock_get):
         """
         Test method _get_token_from_authentication_server for projects with USE_TZ False
         """
         settings_use_tz_backup = settings.USE_TZ
         settings.USE_TZ = False
         try:
-            self.validator._get_token_from_authentication_server(
+            access_token = self.validator._get_token_from_authentication_server(
+                "foo",
+                oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL,
+                oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN,
+                oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS,
+            )
+
+            self.assertFalse(access_token.is_expired())
+        except ValueError as exception:
+            self.fail(str(exception))
+        finally:
+            settings.USE_TZ = settings_use_tz_backup
+
+    @mock.patch("requests.post", side_effect=mocked_introspect_request_short_living_token)
+    def test_get_token_from_authentication_server_expires_utc_timezone(self, mock_get):
+        """
+        Test method _get_token_from_authentication_server for projects with USE_TZ True and a UTC Timezone
+        """
+        settings_use_tz_backup = settings.USE_TZ
+        settings_time_zone_backup = settings.TIME_ZONE
+        settings.USE_TZ = True
+        settings.TIME_ZONE = "UTC"
+        try:
+            access_token = self.validator._get_token_from_authentication_server(
                 "foo",
                 oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL,
                 oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN,
                 oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS,
             )
+
+            self.assertFalse(access_token.is_expired())
+        except ValueError as exception:
+            self.fail(str(exception))
+        finally:
+            settings.USE_TZ = settings_use_tz_backup
+            settings.TIME_ZONE = settings_time_zone_backup
+
+    @mock.patch("requests.post", side_effect=mocked_introspect_request_short_living_token)
+    def test_get_token_from_authentication_server_expires_non_utc_timezone(self, mock_get):
+        """
+        Test method _get_token_from_authentication_server for projects with USE_TZ True and a non UTC Timezone
+
+        This test is important to check if the UTC Exp. date gets converted correctly
+        """
+        settings_use_tz_backup = settings.USE_TZ
+        settings_time_zone_backup = settings.TIME_ZONE
+        settings.USE_TZ = True
+        settings.TIME_ZONE = "Europe/Amsterdam"
+        try:
+            access_token = self.validator._get_token_from_authentication_server(
+                "foo",
+                oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL,
+                oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN,
+                oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS,
+            )
+
+            self.assertFalse(access_token.is_expired())
         except ValueError as exception:
             self.fail(str(exception))
         finally:
             settings.USE_TZ = settings_use_tz_backup
+            settings.TIME_ZONE = settings_time_zone_backup
 
     @mock.patch("requests.post", side_effect=mocked_requests_post)
     def test_validate_bearer_token(self, mock_get):