diff --git a/src/ol_social_auth/README.rst b/src/ol_social_auth/README.rst index 961013962..4a5fdf30b 100644 --- a/src/ol_social_auth/README.rst +++ b/src/ol_social_auth/README.rst @@ -32,3 +32,15 @@ Make sure to properly configure the plugin following the links in the above "Con * Install the plugin in the lms following the installation steps above. * Verify that you are not logged in on edx-platform. * Create a new user in your MIT application and verify that a corresponding user is successfully created on the edX platform. + +Expired Token Cleanup +--------------------- +This plugin includes a scheduled Celery task (``ol_clear_expired_tokens``) that automatically removes expired OAuth2 access tokens, refresh tokens, and grant tokens from the database. + +**Behavior:** + +* Runs every **Monday at 9:00 AM** (server time) via Celery Beat by default. The schedule can be customized by overriding the ``ol_clear_expired_tokens`` entry in ``CELERYBEAT_SCHEDULE``. +* Uses django-oauth-toolkit's ``clear_expired()`` to delete tokens that have exceeded the configured expiration threshold. +* Sets ``REFRESH_TOKEN_EXPIRE_SECONDS`` to **30 days** (overriding the edx-platform default of 90 days). Tokens revoked or expired longer than 30 days ago will be cleaned up. + +**Note:** If running this plugin for the first time on a database with a large backlog of expired tokens (millions of rows), consider running the ``edx_clear_expired_tokens`` management command manually first to reduce the initial volume before relying on the scheduled task. diff --git a/src/ol_social_auth/ol_social_auth/apps.py b/src/ol_social_auth/ol_social_auth/apps.py new file mode 100644 index 000000000..56c7854f4 --- /dev/null +++ b/src/ol_social_auth/ol_social_auth/apps.py @@ -0,0 +1,23 @@ +"""ol_social_auth Django application initialization.""" + +from django.apps import AppConfig +from edx_django_utils.plugins import PluginSettings +from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType + + +class OLSocialAuthConfig(AppConfig): + name = "ol_social_auth" + verbose_name = "OL Social Auth" + + plugin_app = { + PluginSettings.CONFIG: { + ProjectType.LMS: { + SettingsType.COMMON: { + PluginSettings.RELATIVE_PATH: "settings.common", + }, + SettingsType.PRODUCTION: { + PluginSettings.RELATIVE_PATH: "settings.production", + }, + }, + }, + } diff --git a/src/ol_social_auth/ol_social_auth/settings/__init__.py b/src/ol_social_auth/ol_social_auth/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/ol_social_auth/ol_social_auth/settings/common.py b/src/ol_social_auth/ol_social_auth/settings/common.py new file mode 100644 index 000000000..dbe37ec70 --- /dev/null +++ b/src/ol_social_auth/ol_social_auth/settings/common.py @@ -0,0 +1,17 @@ +"""Common settings for the ol-social-auth plugin.""" + +from celery.schedules import crontab + + +def plugin_settings(settings): + """Settings for the ol-social-auth plugin.""" # noqa: D401 + settings.OAUTH2_PROVIDER["REFRESH_TOKEN_EXPIRE_SECONDS"] = ( + 30 * 24 * 60 * 60 # 30 days + ) + # Add ol_clear_expired_tokens to the Celery beat schedule. + if not hasattr(settings, "CELERYBEAT_SCHEDULE"): + settings.CELERYBEAT_SCHEDULE = {} + settings.CELERYBEAT_SCHEDULE["ol_clear_expired_tokens"] = { + "task": "ol_social_auth.tasks.ol_clear_expired_tokens", + "schedule": crontab(hour=9, minute=0, day_of_week="monday"), + } diff --git a/src/ol_social_auth/ol_social_auth/settings/production.py b/src/ol_social_auth/ol_social_auth/settings/production.py new file mode 100644 index 000000000..4a3b9d3b9 --- /dev/null +++ b/src/ol_social_auth/ol_social_auth/settings/production.py @@ -0,0 +1,5 @@ +"""Production settings for the ol-social-auth plugin.""" + + +def plugin_settings(settings): + """Production overrides for ol-social-auth plugin.""" diff --git a/src/ol_social_auth/ol_social_auth/tasks.py b/src/ol_social_auth/ol_social_auth/tasks.py new file mode 100644 index 000000000..64a03ee7f --- /dev/null +++ b/src/ol_social_auth/ol_social_auth/tasks.py @@ -0,0 +1,25 @@ +"""Celery tasks for ol-social-auth plugin.""" + +import logging + +from celery import shared_task +from oauth2_provider.models import clear_expired + +log = logging.getLogger(__name__) +oauth2_logger = logging.getLogger("oauth2_provider") + + +@shared_task(acks_late=True) +def ol_clear_expired_tokens(): + """Clear expired OAuth2 access, refresh, and ID tokens.""" + log.info("Starting ol_clear_expired_tokens...") + # Suppress debug-level logs from oauth2_provider during cleanup. + # Its batch_delete debug logs lack the 'userid' field expected by + # Open edX's custom log formatter, causing noisy ValueError tracebacks. + original_level = oauth2_logger.level + oauth2_logger.setLevel(logging.INFO) + try: + clear_expired() + finally: + oauth2_logger.setLevel(original_level) + log.info("Finished ol_clear_expired_tokens.") diff --git a/src/ol_social_auth/pyproject.toml b/src/ol_social_auth/pyproject.toml index c819964a1..a56cd3e3b 100644 --- a/src/ol_social_auth/pyproject.toml +++ b/src/ol_social_auth/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ol-social-auth" -version = "0.2.0" +version = "0.2.1" description = "An Open edX plugin implementing MIT social auth backend" authors = [ {name = "MIT Office of Digital Learning"} @@ -11,9 +11,13 @@ requires-python = ">=3.11" keywords = ["Python", "edx"] dependencies = [ "Django>=4.0", + "django-oauth-toolkit", "social-auth-core>=4.5.4", ] +[project.entry-points."lms.djangoapp"] +ol_social_auth = "ol_social_auth.apps:OLSocialAuthConfig" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/ol_social_auth/setup.cfg b/src/ol_social_auth/setup.cfg new file mode 100644 index 000000000..78c55b21c --- /dev/null +++ b/src/ol_social_auth/setup.cfg @@ -0,0 +1,26 @@ +[tool:pytest] +pep8maxlinelength = 119 +DJANGO_SETTINGS_MODULE = lms.envs.test +addopts = --nomigrations --reuse-db --durations=20 +filterwarnings = + default + ignore::xblock.exceptions.FieldDataDeprecationWarning + ignore::pytest.PytestConfigWarning + ignore:No request passed to the backend, unable to rate-limit:UserWarning + ignore:Flags not at the start of the expression:DeprecationWarning + ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc':DeprecationWarning + ignore:invalid escape sequence:DeprecationWarning + ignore:`formatargspec` is deprecated since Python 3.5:DeprecationWarning + ignore:the imp module is deprecated in favour of importlib:DeprecationWarning + ignore:"is" with a literal:SyntaxWarning + ignore:defusedxml.lxml is no longer supported:DeprecationWarning + ignore: `np.int` is a deprecated alias for the builtin `int`.:DeprecationWarning + ignore: `np.float` is a deprecated alias for the builtin `float`.:DeprecationWarning + ignore: `np.complex` is a deprecated alias for the builtin `complex`.:DeprecationWarning + ignore: 'etree' is deprecated. Use 'xml.etree.ElementTree' instead.:DeprecationWarning + ignore: defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead.:DeprecationWarning + +junit_family = xunit2 +norecursedirs = .* *.egg build conf dist node_modules test_root cms/envs lms/envs +python_classes = +python_files = tests.py test_*.py tests_*.py *_tests.py __init__.py diff --git a/src/ol_social_auth/tests/tasks_test.py b/src/ol_social_auth/tests/tasks_test.py new file mode 100644 index 000000000..9f37bbec0 --- /dev/null +++ b/src/ol_social_auth/tests/tasks_test.py @@ -0,0 +1,24 @@ +"""Tests for ol_social_auth tasks.""" + +from ol_social_auth import tasks + + +def test_ol_clear_expired_tokens(mocker): + """Test that ol_clear_expired_tokens calls the clear_expired function.""" + patched_clear_expired = mocker.patch("ol_social_auth.tasks.clear_expired") + + tasks.ol_clear_expired_tokens.delay() + patched_clear_expired.assert_called_once_with() + + +def test_ol_clear_expired_tokens_logging(mocker): + """Test that ol_clear_expired_tokens logs start and finish messages.""" + mocker.patch("ol_social_auth.tasks.clear_expired") + patched_log_info = mocker.patch("ol_social_auth.tasks.log.info") + + tasks.ol_clear_expired_tokens() + + expected_log_call_count = 2 + assert patched_log_info.call_count == expected_log_call_count # noqa: S101 + patched_log_info.assert_any_call("Starting ol_clear_expired_tokens...") + patched_log_info.assert_any_call("Finished ol_clear_expired_tokens.")