diff --git a/AUTHORS b/AUTHORS index 048085a11..6a4ef9df8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -107,4 +107,7 @@ Tom Evans Vinay Karanam Víðir Valberg Guðmundsson Will Beaufoy +pySilver +Łukasz Skarżyński +Wouter Klein Heerenbrink Yuri Savin diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe72e91d..b7ddbabb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Fixed +* #1292 Interpret `EXP` in AccessToken always as UTC instead of own key +* #1292 Introduce setting `AUTHENTICATION_SERVER_EXP_TIME_ZONE` to enable different time zone in case remote + authentication server doe snot provide EXP in UTC + ### WARNING * If you are going to revert migration 0006 make note that previously hashed client_secret cannot be reverted diff --git a/docs/settings.rst b/docs/settings.rst index db5ef110b..f7ee76267 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -266,6 +266,12 @@ The number of seconds an authorization token received from the introspection end If the expire time of the received token is less than ``RESOURCE_SERVER_TOKEN_CACHING_SECONDS`` the expire time will be used. +AUTHENTICATION_SERVER_EXP_TIME_ZONE +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The exp (expiration date) of Access Tokens must be defined in UTC (Unix Timestamp). Although its wrong, sometimes +a remote Authentication Server does not use UTC (eg. no timezone support and configured in local time other than UTC). +Prior to fix #1292 this could be fixed by changing your own time zone. With the introduction of this fix, this workaround +would not be possible anymore. This setting re-enables this workaround. PKCE_REQUIRED ~~~~~~~~~~~~~ diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 9c1e02887..37adf4181 100644 --- 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 index e608799e1..950ab5643 100644 --- 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 index de641f74f..3f48723c5 100644 --- 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) diff --git a/setup.cfg b/setup.cfg index 453126c28..d015d1238 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,6 +40,7 @@ install_requires = requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 + pytz >= 2024.1 [options.packages.find] exclude = diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index c4f8231d5..100ef064e 100644 --- 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):