diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index 4ea10718b..12e5c53c8 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -2,6 +2,7 @@ import logging import os +import time from django.conf import settings from django.core.exceptions import SuspiciousOperation @@ -35,6 +36,18 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): in the User and Identity models, and handles signed and/or encrypted UserInfo response. """ + def store_tokens(self, access_token, id_token): + """ + Extends base method to stores a oidc_token_expiration field in the session + which is used with OIDCRefreshSessionMiddleware, which performs a refresh + token request if oidc_token_expiration expires. + """ + session = self.request.session + token_expiration_delay = self.get_settings("OIDC_TOKEN_EXPIRATION", 60 * 15) + session["oidc_token_expiration"] = time.time() + token_expiration_delay + + super().store_tokens(access_token, id_token) + def get_extra_claims(self, user_info): """ Return extra claims from user_info. diff --git a/src/backend/core/authentication/middleware.py b/src/backend/core/authentication/middleware.py new file mode 100644 index 000000000..7a3194b42 --- /dev/null +++ b/src/backend/core/authentication/middleware.py @@ -0,0 +1,23 @@ +"""OIDC Refresh Session Middleware for the Impress core app.""" + +import time + +from lasuite.oidc_login.middleware import RefreshOIDCAccessToken + + +class OIDCRefreshSessionMiddleware(RefreshOIDCAccessToken): + """ + Customizes the process_request method to update the session's + oidc_token_expiration field. + """ + + def process_request(self, request): + """ + Run the base process_request method and + update oidc_token_expiration on success. + """ + token_expiration_delay = self.get_settings("OIDC_TOKEN_EXPIRATION") + if super().is_expired(request) and super().process_request(request) is None: + request.session["oidc_token_expiration"] = ( + time.time() + token_expiration_delay + ) diff --git a/src/backend/core/tests/authentication/test_middleware.py b/src/backend/core/tests/authentication/test_middleware.py new file mode 100644 index 000000000..7cbc2f795 --- /dev/null +++ b/src/backend/core/tests/authentication/test_middleware.py @@ -0,0 +1,109 @@ +"""Unit tests for the OIDC Refresh Session Middleware.""" + +import re +import time +from unittest.mock import MagicMock + +import pytest +import responses +from cryptography.fernet import Fernet +from lasuite.oidc_login.backends import ( + get_oidc_refresh_token, + store_oidc_refresh_token, +) + +from core.authentication.middleware import OIDCRefreshSessionMiddleware +from core.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +@responses.activate +def test_refresh_access_token_when_expired(rf, settings): + """ + Test if the middleware refreshes the access token and updates the oidc_token_expiration field + """ + settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token" + settings.OIDC_OP_AUTHORIZATION_ENDPOINT = "https://oidc.endpoint.com/authorize" + settings.OIDC_RP_CLIENT_ID = "client_id" + settings.OIDC_RP_CLIENT_SECRET = "client_secret" + settings.OIDC_RP_SCOPES = "openid email" + settings.OIDC_USE_NONCE = True + settings.OIDC_VERIFY_SSL = True + settings.OIDC_TOKEN_USE_BASIC_AUTH = False + settings.OIDC_STORE_ACCESS_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key() + settings.OIDC_TOKEN_EXPIRATION = 100 + + request = rf.get("/some-url") + request.user = UserFactory() + request.session = {} + request.session["oidc_access_token"] = "old-access-token" + store_oidc_refresh_token(request.session, "old-refresh_token") + + now = time.time() + expiration = now - settings.OIDC_TOKEN_EXPIRATION + request.session["oidc_token_expiration"] = expiration + + get_response = MagicMock() + refresh_middleware = OIDCRefreshSessionMiddleware(get_response) + + responses.add( + responses.POST, + re.compile(settings.OIDC_OP_TOKEN_ENDPOINT), + json={ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + }, + status=200, + ) + + # pylint: disable=assignment-from-no-return + response = refresh_middleware.process_request(request) + + assert response is None + assert request.session["oidc_access_token"] == "new-access-token" + assert request.session["oidc_id_token"] is None + assert ( + request.session["oidc_token_expiration"] + > expiration + settings.OIDC_TOKEN_EXPIRATION + ) + assert get_oidc_refresh_token(request.session) == "new-refresh-token" + + +def test_access_token_when_expired(rf, settings): + """ + Test if the middleware doesn't perform a token refresh if token not expired + """ + + settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token" + settings.OIDC_OP_AUTHORIZATION_ENDPOINT = "https://oidc.endpoint.com/authorize" + settings.OIDC_RP_CLIENT_ID = "client_id" + settings.OIDC_RP_CLIENT_SECRET = "client_secret" + settings.OIDC_RP_SCOPES = "openid email" + settings.OIDC_USE_NONCE = True + settings.OIDC_VERIFY_SSL = True + settings.OIDC_TOKEN_USE_BASIC_AUTH = False + settings.OIDC_STORE_ACCESS_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key() + settings.OIDC_TOKEN_EXPIRATION = 100 + + request = rf.get("/some-url") + request.user = UserFactory() + request.session = {} + request.session["oidc_access_token"] = "access-token" + store_oidc_refresh_token(request.session, "refresh_token") + + expiration = time.time() + 120 + request.session["oidc_token_expiration"] = expiration + + get_response = MagicMock() + refresh_middleware = OIDCRefreshSessionMiddleware(get_response) + + # pylint: disable=assignment-from-no-return + response = refresh_middleware.process_request(request) + + assert response is None + assert request.session["oidc_token_expiration"] == expiration diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 571d7052d..c8cc3c6c9 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -18,6 +18,7 @@ import sentry_sdk from configurations import Configuration, values +from cryptography.fernet import Fernet from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.logging import ignore_logger @@ -283,6 +284,7 @@ class Base(Configuration): "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "core.authentication.middleware.OIDCRefreshSessionMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "dockerflow.django.middleware.DockerflowMiddleware", ] @@ -463,7 +465,9 @@ class Base(Configuration): SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_CACHE_ALIAS = "default" SESSION_COOKIE_AGE = values.PositiveIntegerValue( - default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None + default=60 * 60 * 24 * 5, # 5 days + environ_name="SESSION_COOKIE_AGE", + environ_prefix=None, ) # OIDC - Authorization Code Flow @@ -541,16 +545,21 @@ class Base(Configuration): default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None ) OIDC_STORE_ACCESS_TOKEN = values.BooleanValue( - default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None + default=True, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None ) OIDC_STORE_REFRESH_TOKEN = values.BooleanValue( - default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None + default=True, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None ) OIDC_STORE_REFRESH_TOKEN_KEY = values.Value( - default=None, + default=Fernet.generate_key(), environ_name="OIDC_STORE_REFRESH_TOKEN_KEY", environ_prefix=None, ) + OIDC_TOKEN_EXPIRATION = values.PositiveIntegerValue( + default=60 * 15, # 15 minutes + environ_name="OIDC_TOKEN_EXPIRATION", + environ_prefix=None, + ) # WARNING: Enabling this setting allows multiple user accounts to share the same email # address. This may cause security issues and is not recommended for production use when diff --git a/src/frontend/package.json b/src/frontend/package.json index 17308b2c1..fe17647a9 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -38,5 +38,6 @@ "react-dom": "19.1.0", "typescript": "5.8.3", "yjs": "13.6.27" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" }