diff --git a/credentials/apps/badges/credly/api_client.py b/credentials/apps/badges/credly/api_client.py index b35eddc7d..e25610c11 100644 --- a/credentials/apps/badges/credly/api_client.py +++ b/credentials/apps/badges/credly/api_client.py @@ -8,6 +8,7 @@ from attrs import asdict from django.conf import settings from django.contrib.sites.models import Site +from django.utils import timezone from credentials.apps.badges.base_api_client import BaseBadgeProviderClient from credentials.apps.badges.credly.exceptions import CredlyError @@ -149,6 +150,37 @@ def revoke_badge(self, badge_id, data=None): """ return self.perform_request("put", f"badges/{badge_id}/revoke/", data=data) + def rotate_authorization_token(self): + """ + Rotate (refresh) the Credly authorization token for this organization. + + Calls Credly's token rotation endpoint, which generates and returns a new + authorization token and immediately invalidates the token used to make the + request. The new token and its issuance timestamp are persisted on the + related CredlyOrganization. + + Returns: + str: the newly issued authorization token. + + Raises: + CredlyError: if the rotation response does not contain a new token. + """ + response = self.perform_request("post", "authorization_tokens/rotate") + new_token = response.get("data", {}).get("token") + if not new_token: + raise CredlyError("Credly token rotation response did not contain a new token.") + + organization = self._get_organization(self.organization_id) + organization.authorization_token_issued_at = timezone.now() + organization.api_key = new_token + organization.save(update_fields=["api_key", "authorization_token_issued_at", "modified"]) + + # Adopt the new token for any subsequent requests and drop the cached header. + self.api_key = new_token + self._build_authorization_token.cache_clear() + + return new_token + def sync_organization_badge_templates(self, site_id): """ Pull active badge templates for a given Credly Organization. diff --git a/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py b/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py new file mode 100644 index 000000000..3d8c570f4 --- /dev/null +++ b/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py @@ -0,0 +1,100 @@ +import logging + +from django.core.management.base import BaseCommand + +from credentials.apps.badges.credly.api_client import CredlyAPIClient +from credentials.apps.badges.exceptions import BadgesError +from credentials.apps.badges.models import CredlyOrganization + +logger = logging.getLogger(__name__) + +# Start warning operators once a token is within this many days of expiration. +WARNING_THRESHOLD_DAYS = 30 +# Automatically rotate a token once fewer than this many days remain. +REFRESH_THRESHOLD_DAYS = 5 + + +class Command(BaseCommand): + help = ( + "Check Credly authorization tokens and keep them alive: log a warning when a token is " + f"within {WARNING_THRESHOLD_DAYS} days of expiration and automatically rotate it once fewer " + f"than {REFRESH_THRESHOLD_DAYS} days remain. Intended to be run periodically (e.g. daily cron)." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--organization_id", + type=str, + help="UUID of a single Credly organization to check. Defaults to all organizations.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Rotate the token immediately, regardless of the remaining lifetime.", + ) + + def handle(self, *args, **options): + """ + Refresh Credly authorization tokens that are close to expiration. + + Usage: + ./manage.py refresh_credly_authorization_tokens + ./manage.py refresh_credly_authorization_tokens --organization_id + ./manage.py refresh_credly_authorization_tokens --force + """ + organization_id = options.get("organization_id") + force = options.get("force") + + if organization_id: + organizations = CredlyOrganization.objects.filter(uuid=organization_id) + if not organizations: + logger.warning(f"No Credly organization found with the uuid {organization_id}.") + else: + organizations = CredlyOrganization.objects.all() + + for organization in organizations: + self._process_organization(organization, force=force) + + logger.info("...completed!") + + def _process_organization(self, organization, force=False): + """ + Inspect a single organization's token and warn and/or rotate as needed. + """ + if force: + logger.info(f"Organization {organization.uuid}: forcing authorization token rotation.") + self._rotate(organization) + return + + days_left = organization.authorization_token_days_until_expiry + + if days_left is None: + logger.warning( + f"Organization {organization.uuid}: authorization token issuance date is unknown, so its " + "expiration cannot be determined. Rotate it manually or run this command with --force." + ) + return + + if days_left < REFRESH_THRESHOLD_DAYS: + logger.warning( + f"Organization {organization.uuid}: authorization token expires in {days_left} day(s); " + "rotating it now." + ) + self._rotate(organization) + elif days_left <= WARNING_THRESHOLD_DAYS: + logger.warning( + f"Organization {organization.uuid}: authorization token expires in {days_left} day(s). " + f"It will be rotated automatically once fewer than {REFRESH_THRESHOLD_DAYS} days remain." + ) + else: + logger.info(f"Organization {organization.uuid}: authorization token is healthy ({days_left} day(s) left).") + + def _rotate(self, organization): + """ + Rotate a single organization's authorization token, logging the outcome. + """ + try: + CredlyAPIClient(organization.uuid).rotate_authorization_token() + logger.info(f"Organization {organization.uuid}: authorization token rotated successfully.") + except BadgesError as exc: + logger.error(f"Organization {organization.uuid}: failed to rotate authorization token: {exc}") diff --git a/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_issued_at.py b/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_issued_at.py new file mode 100644 index 000000000..ef793303a --- /dev/null +++ b/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_issued_at.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.11 on 2026-08-10 13:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("badges", "0002_accredibleapiconfig_accrediblebadge_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="credlyorganization", + name="authorization_token_issued_at", + field=models.DateTimeField( + blank=True, + help_text="When the current Credly authorization token was issued or last rotated.", + null=True, + ), + ), + ] diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py index 5c21fb128..bfada7179 100644 --- a/credentials/apps/badges/models.py +++ b/credentials/apps/badges/models.py @@ -5,10 +5,12 @@ import logging import operator import uuid +from datetime import timedelta from urllib.parse import urljoin from django.conf import settings from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_extensions.db.models import TimeStampedModel from model_utils import Choices @@ -35,6 +37,9 @@ class CredlyOrganization(TimeStampedModel): Credly Organization configuration. """ + # Credly authorization tokens are long-lived credentials that expire 180 days after issuance. + AUTHORIZATION_TOKEN_LIFETIME = timedelta(days=180) + uuid = models.UUIDField(unique=True, help_text=_("Put your Credly Organization ID here.")) api_key = models.CharField( max_length=255, @@ -47,10 +52,39 @@ class CredlyOrganization(TimeStampedModel): blank=True, help_text=_("Verbose name for Credly Organization."), ) + authorization_token_issued_at = models.DateTimeField( + null=True, + blank=True, + help_text=_("When the current Credly authorization token was issued or last rotated."), + ) def __str__(self): return f"{self.name or self.uuid}" + @property + def authorization_token_expires_at(self): + """ + Estimated expiration datetime of the current authorization token. + + Derived from the recorded issuance timestamp plus the token lifetime. + Returns None when no issuance date has been recorded yet. + """ + if self.authorization_token_issued_at is None: + return None + return self.authorization_token_issued_at + self.AUTHORIZATION_TOKEN_LIFETIME + + @property + def authorization_token_days_until_expiry(self): + """ + Number of whole days remaining before the authorization token expires. + + Returns None when the expiration cannot be determined (no recorded dates). + """ + expires_at = self.authorization_token_expires_at + if expires_at is None: + return None + return (expires_at - timezone.now()).days + @classmethod def get_all_organization_ids(cls): """ diff --git a/credentials/apps/badges/tests/test_api_client.py b/credentials/apps/badges/tests/test_api_client.py index 50c173f92..144d0b66d 100644 --- a/credentials/apps/badges/tests/test_api_client.py +++ b/credentials/apps/badges/tests/test_api_client.py @@ -104,6 +104,27 @@ def test_revoke_badge(self): mock_perform_request.assert_called_once_with("put", f"badges/{badge_id}/revoke/", data=data) self.assertEqual(result, {"badge": "revoked"}) + def test_rotate_authorization_token(self): + api_client = CredlyAPIClient(self.organization.uuid) + with mock.patch.object(CredlyAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"data": {"token": "new-token"}} + result = api_client.rotate_authorization_token() + + mock_perform_request.assert_called_once_with("post", "authorization_tokens/rotate") + self.assertEqual(result, "new-token") + self.assertEqual(api_client.api_key, "new-token") + + self.organization.refresh_from_db() + self.assertEqual(self.organization.api_key, "new-token") + self.assertIsNotNone(self.organization.authorization_token_issued_at) + + def test_rotate_authorization_token_no_token_in_response(self): + api_client = CredlyAPIClient(self.organization.uuid) + with mock.patch.object(CredlyAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"data": {}} + with self.assertRaises(CredlyError): + api_client.rotate_authorization_token() + def test_sync_organization_badge_templates(self): with mock.patch.object(CredlyAPIClient, "fetch_badge_templates") as mock_fetch_badge_templates: mock_fetch_badge_templates.return_value = { diff --git a/credentials/apps/badges/tests/test_management_commands.py b/credentials/apps/badges/tests/test_management_commands.py index 963819498..2bab5c92b 100644 --- a/credentials/apps/badges/tests/test_management_commands.py +++ b/credentials/apps/badges/tests/test_management_commands.py @@ -1,8 +1,10 @@ +from datetime import timedelta from unittest import mock import faker from django.core.management import call_command from django.test import TestCase +from django.utils import timezone from credentials.apps.badges.models import AccredibleAPIConfig, CredlyOrganization @@ -47,3 +49,73 @@ def test_handle_with_api_config_id(self, mock_accredible_api_client): call_command("sync_accredible_groups", "--api_config_id", self.api_config.id) mock_accredible_api_client.assert_called_once_with(1) mock_accredible_api_client.return_value.sync_groups.assert_called_once_with(1) + + +@mock.patch("credentials.apps.badges.management.commands.refresh_credly_authorization_tokens.CredlyAPIClient") +class TestRefreshCredlyAuthorizationTokensCommand(TestCase): + """ + Tests for the ``refresh_credly_authorization_tokens`` management command. + + Token expiration is derived from ``authorization_token_issued_at`` plus the 180-day lifetime, so + a token with ``days_left`` remaining is simulated by backdating that field accordingly. A 12-hour + buffer is added to keep the truncated day count off threshold boundaries. + """ + + COMMAND = "refresh_credly_authorization_tokens" + + def setUp(self): + self.faker = faker.Faker() + + def _make_organization(self, days_left=None): + organization = CredlyOrganization.objects.create( + uuid=self.faker.uuid4(), api_key=self.faker.uuid4(), name=self.faker.word() + ) + if days_left is not None: + lifetime = CredlyOrganization.AUTHORIZATION_TOKEN_LIFETIME + organization.authorization_token_issued_at = timezone.now() - lifetime + timedelta(days=days_left, hours=12) + organization.save() + # Reload so ``organization.uuid`` is a UUID instance, as the command sees it when querying the DB. + organization.refresh_from_db() + return organization + + def test_healthy_token_is_not_rotated(self, mock_credly_api_client): + self._make_organization(days_left=100) + call_command(self.COMMAND) + mock_credly_api_client.return_value.rotate_authorization_token.assert_not_called() + + def test_token_in_warning_window_is_not_rotated(self, mock_credly_api_client): + self._make_organization(days_left=20) + with self.assertLogs( + "credentials.apps.badges.management.commands.refresh_credly_authorization_tokens", + level="WARNING", + ) as logs: + call_command(self.COMMAND) + mock_credly_api_client.return_value.rotate_authorization_token.assert_not_called() + self.assertTrue(any("expires in" in message for message in logs.output)) + + def test_token_close_to_expiry_is_rotated(self, mock_credly_api_client): + organization = self._make_organization(days_left=2) + call_command(self.COMMAND) + mock_credly_api_client.assert_called_once_with(organization.uuid) + mock_credly_api_client.return_value.rotate_authorization_token.assert_called_once_with() + + def test_token_without_dates_is_warned_not_rotated(self, mock_credly_api_client): + self._make_organization(days_left=None) + with self.assertLogs( + "credentials.apps.badges.management.commands.refresh_credly_authorization_tokens", + level="WARNING", + ): + call_command(self.COMMAND) + mock_credly_api_client.return_value.rotate_authorization_token.assert_not_called() + + def test_force_rotates_regardless_of_expiry(self, mock_credly_api_client): + organization = self._make_organization(days_left=100) + call_command(self.COMMAND, "--force") + mock_credly_api_client.assert_called_once_with(organization.uuid) + mock_credly_api_client.return_value.rotate_authorization_token.assert_called_once_with() + + def test_organization_id_limits_scope(self, mock_credly_api_client): + target = self._make_organization(days_left=2) + self._make_organization(days_left=2) + call_command(self.COMMAND, "--organization_id", target.uuid) + mock_credly_api_client.assert_called_once_with(target.uuid) diff --git a/credentials/apps/badges/tests/test_models.py b/credentials/apps/badges/tests/test_models.py index 879192f01..4c60be6d6 100644 --- a/credentials/apps/badges/tests/test_models.py +++ b/credentials/apps/badges/tests/test_models.py @@ -1,10 +1,12 @@ import uuid +from datetime import timedelta from unittest.mock import patch from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site from django.test import TestCase +from django.utils import timezone from faker import Faker from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData @@ -584,6 +586,14 @@ def test_is_preconfigured(self): self.assertTrue(self.organization.is_preconfigured) mock_get_preconfigured.assert_called_once() + def test_authorization_token_expiry_unknown_without_issuance_date(self): + self.assertIsNone(self.organization.authorization_token_expires_at) + self.assertIsNone(self.organization.authorization_token_days_until_expiry) + + def test_authorization_token_expiry_derived_from_issuance_date(self): + self.organization.authorization_token_issued_at = timezone.now() - timedelta(days=170) + self.assertEqual(self.organization.authorization_token_days_until_expiry, 9) + class BadgeProgressTestCase(TestCase): def setUp(self): diff --git a/credentials/conf/locale/en/LC_MESSAGES/django.po b/credentials/conf/locale/en/LC_MESSAGES/django.po index 1fafa911d..155b59b1d 100644 --- a/credentials/conf/locale/en/LC_MESSAGES/django.po +++ b/credentials/conf/locale/en/LC_MESSAGES/django.po @@ -98,6 +98,11 @@ msgstr "" msgid "Verbose name for Credly Organization." msgstr "" +#: apps/badges/models.py +msgid "" +"When the current Credly authorization token was issued or last rotated." +msgstr "" + #: apps/badges/models.py msgid "Unique badge template ID." msgstr "" diff --git a/credentials/conf/locale/eo/LC_MESSAGES/django.mo b/credentials/conf/locale/eo/LC_MESSAGES/django.mo index 38f651d08..26b1a4235 100644 Binary files a/credentials/conf/locale/eo/LC_MESSAGES/django.mo and b/credentials/conf/locale/eo/LC_MESSAGES/django.mo differ diff --git a/credentials/conf/locale/eo/LC_MESSAGES/django.po b/credentials/conf/locale/eo/LC_MESSAGES/django.po index c86b7f351..4df4b2b71 100644 --- a/credentials/conf/locale/eo/LC_MESSAGES/django.po +++ b/credentials/conf/locale/eo/LC_MESSAGES/django.po @@ -129,6 +129,13 @@ msgstr "" "Vérßösé nämé för Çrédlý Örgänïzätïön. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " "¢σηѕє¢тєтυ#" +#: apps/badges/models.py +msgid "" +"When the current Credly authorization token was issued or last rotated." +msgstr "" +"Whén thé çürrént Çrédlý äüthörïzätïön tökén wäs ïssüéd ör läst rötätéd. " +"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#" + #: apps/badges/models.py msgid "Unique badge template ID." msgstr "Ûnïqüé ßädgé témpläté ÌD. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" diff --git a/credentials/conf/locale/eo/LC_MESSAGES/djangojs.mo b/credentials/conf/locale/eo/LC_MESSAGES/djangojs.mo index 96571b0e3..7000a44bc 100644 Binary files a/credentials/conf/locale/eo/LC_MESSAGES/djangojs.mo and b/credentials/conf/locale/eo/LC_MESSAGES/djangojs.mo differ diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/django.mo b/credentials/conf/locale/rtl/LC_MESSAGES/django.mo index 86fa7d72e..fd23d8a00 100644 Binary files a/credentials/conf/locale/rtl/LC_MESSAGES/django.mo and b/credentials/conf/locale/rtl/LC_MESSAGES/django.mo differ diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/django.po b/credentials/conf/locale/rtl/LC_MESSAGES/django.po index 10b56b737..01cf182af 100644 --- a/credentials/conf/locale/rtl/LC_MESSAGES/django.po +++ b/credentials/conf/locale/rtl/LC_MESSAGES/django.po @@ -103,6 +103,12 @@ msgstr "Ȼɹǝdlʎ ȺⱣƗ sɥɐɹǝd sǝɔɹǝʇ ɟøɹ Ȼɹǝdlʎ Øɹƃɐnᴉ msgid "Verbose name for Credly Organization." msgstr "Vǝɹbøsǝ nɐɯǝ ɟøɹ Ȼɹǝdlʎ Øɹƃɐnᴉzɐʇᴉøn." +#: apps/badges/models.py +msgid "" +"When the current Credly authorization token was issued or last rotated." +msgstr "" +"Wɥǝn ʇɥǝ ɔnɹɹǝnʇ Ȼɹǝdlʎ ɐnʇɥøɹᴉzɐʇᴉøn ʇøʞǝn ʍɐs ᴉssnǝd øɹ lɐsʇ ɹøʇɐʇǝd." + #: apps/badges/models.py msgid "Unique badge template ID." msgstr "Ʉnᴉbnǝ bɐdƃǝ ʇǝɯdlɐʇǝ ƗĐ." diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.mo b/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.mo index 71d10356e..855ece954 100644 Binary files a/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.mo and b/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.mo differ diff --git a/docs/sharing/badges/configuration/credly.rst b/docs/sharing/badges/configuration/credly.rst index be30a503a..7f1dca030 100644 --- a/docs/sharing/badges/configuration/credly.rst +++ b/docs/sharing/badges/configuration/credly.rst @@ -16,13 +16,66 @@ To configure a Credly organization in Open edX Credentials, navigate to ``https: #. Set the **UUID** to your Credly Organization identifier. #. Set the **API key** used to authenticate with the Credly Organization. -.. note:: - - Credly API keys have a limited lifetime of 180 days. Rotate them before expiry. See `Auth Tokens for Authorization `_. - The system pulls the Organization's details and updates its name. If errors occur, verify the API key and UUID for the Organization. +The API key you configured expires 180 days after Credly issues it. +Rotate keys before they expire, as described in :ref:`badges-credly-token-rotation` below. + +.. _badges-credly-token-rotation: + +API Key Rotation +---------------- + +Credly API keys expire 180 days after they are issued. +See `Auth Tokens for Authorization `__ for the Credly-side token lifecycle. + +Once an API key expires, issuing badges, revoking badges, and synchronizing badge templates for that Organization fail until you replace the key. + +Open edX Credentials records when each Organization's API key was issued or last rotated, and provides the ``refresh_credly_authorization_tokens`` management command to rotate keys before they expire. + +For each checked Organization, the command: + +- Logs a warning when the key expires in 30 days or fewer. +- Rotates the key through the Credly `rotate authorization token `__ API when fewer than 5 days remain, and stores the new value on the Credly Organization record. As described in `Credly Authentication Methods `__, the previous key immediately becomes unavailable for use. +- Logs a warning and does not rotate when the key issuance date is unknown. This applies to every Organization configured before Open edX Credentials started tracking key dates. Rotate such keys once with ``--force`` to record the issuance date and enable automatic rotation from then on. + +If you are using Tutor: + +.. code-block:: bash + + tutor local exec credentials ./manage.py refresh_credly_authorization_tokens + +For other installations, run the command directly in the Credentials service: + +.. code-block:: bash + + ./manage.py refresh_credly_authorization_tokens + +By default, the command checks every configured Organization and rotates only the keys that are close to expiry. +The following options change this behavior: + +``--organization_id `` + Check and rotate the key of a single Organization instead of all configured Organizations. + +``--force`` + Rotate keys immediately, regardless of the remaining lifetime. + Without ``--organization_id``, this rotates the keys of all configured Organizations at once. + +.. warning:: + + Rotation invalidates the current API key on the Credly side. + If other services authenticate with the same Credly Organization key, they lose access until you share the new key with them. + +Scheduled Rotation +~~~~~~~~~~~~~~~~~~ + +Run the command on a daily schedule so API keys are checked and rotated without operator involvement. +A daily run is sufficient because the command only acts when a key approaches expiry. +Use the scheduling mechanism of your deployment, such as a cron job on the Credentials host or a Kubernetes ``CronJob``. + +Review the command output in your logs: warnings signal keys nearing expiry, and errors signal failed rotations that need manual attention. + Badge Templates --------------- diff --git a/docs/sharing/badges/quickstart.rst b/docs/sharing/badges/quickstart.rst index 1b60622ef..00bf360f2 100644 --- a/docs/sharing/badges/quickstart.rst +++ b/docs/sharing/badges/quickstart.rst @@ -97,6 +97,10 @@ For **Credly**: #. Verify the system pulls the organization's data and updates its name. +The API key you configured expires 180 days after Credly issues it. +Schedule the ``refresh_credly_authorization_tokens`` management command to rotate keys before they expire, so the integration keeps working without manual key updates. +See :ref:`badges-credly-token-rotation` for details. + For **Accredible**: