diff --git a/credentials/apps/badges/admin.py b/credentials/apps/badges/admin.py index 82dbf2663..52f58082b 100644 --- a/credentials/apps/badges/admin.py +++ b/credentials/apps/badges/admin.py @@ -159,13 +159,39 @@ class CredlyOrganizationAdmin(admin.ModelAdmin): list_display = ( "name", "uuid", + "oauth_client_id", "api_key_hidden", ) - fields = [ - "name", - "uuid", - "api_key_hidden", - ] + fieldsets = ( + ( + _("General Information"), + { + "fields": ( + "name", + "uuid", + ), + }, + ), + ( + _("OAuth 2.0 Credentials"), + { + "description": _("Authentication credentials for OAuth 2.0 Client Credentials flow."), + "fields": ( + "oauth_client_id", + "oauth_client_secret", + ), + }, + ), + ( + _("Legacy API Key Authentication"), + { + "description": _( + "Legacy Basic Auth API key. Use this only if OAuth 2.0 credentials are not available." + ), + "fields": ("api_key",), + }, + ), + ) readonly_fields = [ "name", ] @@ -194,22 +220,34 @@ def api_key_hidden(self, obj): return _("Pre-configured from the environment.") if obj.is_preconfigured else obj.api_key - def get_fields(self, request, obj=None): - fields = super().get_fields(request, obj) + def get_fieldsets(self, request, obj=None): + """ + Show the masked API key instead of the editable one for pre-configured organizations. + """ + fieldsets = super().get_fieldsets(request, obj) if not (obj and obj.is_preconfigured): - fields = [field for field in fields if field != "api_key_hidden"] - fields.append("api_key") - return fields + return fieldsets + + return tuple( + ( + name, + { + **options, + "fields": tuple("api_key_hidden" if field == "api_key" else field for field in options["fields"]), + }, + ) + for name, options in fieldsets + ) def get_readonly_fields(self, request, obj=None): + """ + Credentials of pre-configured organizations come from the environment and can't be edited. + """ readonly_fields = list(super().get_readonly_fields(request, obj)) - if not obj: - return readonly_fields - - if obj.is_preconfigured: - readonly_fields.append("api_key_hidden") + if obj and obj.is_preconfigured: + readonly_fields.extend(["api_key_hidden", "oauth_client_id", "oauth_client_secret"]) return readonly_fields diff --git a/credentials/apps/badges/admin_forms.py b/credentials/apps/badges/admin_forms.py index 89b70be1d..a0e6cabc1 100644 --- a/credentials/apps/badges/admin_forms.py +++ b/credentials/apps/badges/admin_forms.py @@ -8,7 +8,8 @@ from model_utils import Choices from credentials.apps.badges.credly.api_client import CredlyAPIClient -from credentials.apps.badges.credly.exceptions import CredlyAPIError +from credentials.apps.badges.credly.exceptions import CredlyError +from credentials.apps.badges.exceptions import BadgeProviderError from credentials.apps.badges.models import ( AbstractDataRule, BadgePenalty, @@ -42,21 +43,45 @@ def clean(self): uuid = cleaned_data.get("uuid") api_key = cleaned_data.get("api_key") + oauth_client_id = cleaned_data.get("oauth_client_id") + oauth_client_secret = cleaned_data.get("oauth_client_secret") - if str(uuid) in CredlyOrganization.get_preconfigured_organizations().keys(): - if api_key: - raise forms.ValidationError(_("You can't provide an API key for a configured organization.")) + is_preconfigured = str(uuid) in CredlyOrganization.get_preconfigured_organizations().keys() + if is_preconfigured: + if api_key or oauth_client_id or oauth_client_secret: + raise forms.ValidationError( + _("You can't provide API keys or OAuth credentials for a pre-configured organization.") + ) api_key = settings.BADGES_CONFIG["credly"]["ORGANIZATIONS"][str(uuid)] - credly_api_client = CredlyAPIClient(uuid, api_key) + else: + has_oauth = bool(oauth_client_id and oauth_client_secret) + has_api_key = bool(api_key) + + if not (has_oauth or has_api_key): + raise forms.ValidationError( + _("You must provide either OAuth credentials (Client ID & Client Secret) or a legacy API Key.") + ) + + if bool(oauth_client_id) != bool(oauth_client_secret): + raise forms.ValidationError( + _("Both Client ID and Client Secret are required for OAuth authentication.") + ) + + credly_api_client = CredlyAPIClient( + organization_id=uuid, + api_key=api_key, + oauth_client_id=oauth_client_id, + oauth_client_secret=oauth_client_secret, + ) self.ensure_organization_exists(credly_api_client) return cleaned_data def save(self, commit=True): """ - Auto-fill addition properties. + Auto-fill additional properties. """ instance = super().save(commit=False) instance.name = self.api_data.get("name") @@ -69,11 +94,35 @@ def ensure_organization_exists(self, api_client): Try to fetch organization data by the configured Credly Organization ID. """ try: - response_json = api_client.fetch_organization() - if org_data := response_json.get("data"): - self.api_data = org_data - except CredlyAPIError as err: - raise forms.ValidationError(message=str(err)) + if api_client.oauth_client_id and api_client.oauth_client_secret: + response_json = api_client.fetch_badge_templates() + + templates = response_json.get("data", []) + org_name = None + + if templates and isinstance(templates, list): + first_template = templates[0] + owner = first_template.get("owner", {}) + org_name = owner.get("name") + + if not org_name: + org_name = f"Credly Organization ({api_client.organization_id})" + + self.api_data = {"name": org_name} + else: + response_json = api_client.fetch_organization() + if org_data := response_json.get("data"): + self.api_data = org_data + + except (CredlyError, BadgeProviderError) as exc: + if "Status(401)" in str(exc) or "Unauthorized" in str(exc): + raise forms.ValidationError( + _("Invalid OAuth credentials or API key. Credly rejected the authentication request.") + ) from exc + + raise forms.ValidationError( + _("Error communicating with Credly API: %(error)s") % {"error": str(exc)} + ) from exc class BadgePenaltyForm(forms.ModelForm): diff --git a/credentials/apps/badges/base_api_client.py b/credentials/apps/badges/base_api_client.py index 4ea83a2cf..b081d9b87 100644 --- a/credentials/apps/badges/base_api_client.py +++ b/credentials/apps/badges/base_api_client.py @@ -64,7 +64,9 @@ def _raise_for_error(self, response): logger.error( f"Error while processing {self.PROVIDER_NAME} request: {response.status_code} - {response.text}" ) - raise BadgeProviderError(f"{response.text} Status({response.status_code})") + raise BadgeProviderError( + f"{response.text} Status({response.status_code})", status_code=response.status_code + ) @property def base_api_url(self): diff --git a/credentials/apps/badges/credly/api_client.py b/credentials/apps/badges/credly/api_client.py index 72ca64058..fe88402a3 100644 --- a/credentials/apps/badges/credly/api_client.py +++ b/credentials/apps/badges/credly/api_client.py @@ -8,10 +8,12 @@ from attrs import asdict from django.conf import settings from django.contrib.sites.models import Site +from django.core.cache import cache from credentials.apps.badges.base_api_client import BaseBadgeProviderClient from credentials.apps.badges.credly.exceptions import CredlyError from credentials.apps.badges.credly.utils import get_credly_api_base_url +from credentials.apps.badges.exceptions import BadgeProviderError from credentials.apps.badges.models import CredlyBadgeTemplate, CredlyOrganization logger = logging.getLogger(__name__) @@ -28,20 +30,34 @@ class CredlyAPIClient(BaseBadgeProviderClient): PROVIDER_NAME = "Credly" - def __init__(self, organization_id, api_key=None): # pylint: disable=super-init-not-called + def __init__( + self, + organization_id, + api_key=None, + oauth_client_id=None, + oauth_client_secret=None, + ): # pylint: disable=super-init-not-called """ Initializes a CredlyRestAPI object. Args: organization_id (str, uuid): ID of the organization. - api_key (str): optional ID of the organization. + api_key (str): Optional legacy API key of the organization. + oauth_client_id (str): Optional OAuth Client ID. + oauth_client_secret (str): Optional OAuth Client Secret. """ - if api_key is None: + self.organization_id = organization_id + self.organization = None + + if not (api_key or (oauth_client_id and oauth_client_secret)): self.organization = self._get_organization(organization_id) api_key = self.organization.api_key + oauth_client_id = getattr(self.organization, "oauth_client_id", None) + oauth_client_secret = getattr(self.organization, "oauth_client_secret", None) self.api_key = api_key - self.organization_id = organization_id + self.oauth_client_id = oauth_client_id + self.oauth_client_secret = oauth_client_secret def _get_base_api_url(self): return urljoin(get_credly_api_base_url(settings), f"organizations/{self.organization_id}/") @@ -56,24 +72,116 @@ def _get_organization(self, organization_id): except CredlyOrganization.DoesNotExist: raise CredlyError(f"CredlyOrganization with the uuid {organization_id} does not exist!") + @property + def uses_oauth(self): + """ + OAuth takes precedence over the legacy API key when both are configured. + """ + return bool(self.oauth_client_id and self.oauth_client_secret) + + @property + def _oauth_token_cache_key(self): + """ + Keyed by client ID, so organizations sharing an OAuth application share the token. + """ + return f"credly_oauth_access_token_{self.oauth_client_id}" + + def _get_oauth_token(self, force_refresh=False): + """ + Obtains a Bearer Access Token from Credly OAuth endpoint using Client Credentials grant. + + Tokens are cached for their lifetime (minus a safety margin). + + Args: + force_refresh (bool): skip the cached token and request a new one. + """ + if not self.uses_oauth: + return None + + token = None if force_refresh else cache.get(self._oauth_token_cache_key) + if token: + return token + + token_url = urljoin(get_credly_api_base_url(settings), "/oauth/token") + payload = { + "grant_type": "client_credentials", + "scope": "badge_templates issued_badges", + } + + try: + response = requests.post( + token_url, + data=payload, + auth=(self.oauth_client_id, self.oauth_client_secret), + headers={"Accept": "application/json"}, + timeout=self.REQUESTS_TIMEOUT, + ) + response.raise_for_status() + except requests.RequestException as exc: + raise CredlyError(f"Failed to fetch OAuth token from Credly: {str(exc)}") from exc + + try: + data = response.json() + except ValueError as exc: + raise CredlyError(f"Credly returned a non-JSON OAuth token response: {response.text[:200]}") from exc + + token = data.get("access_token") + if not token: + raise CredlyError(f"Credly response did not contain access_token: {data}") + + expires_in = data.get("expires_in", 7200) + cache.set(self._oauth_token_cache_key, token, timeout=max(expires_in - 60, 60)) + + return token + + def perform_request(self, method, url_suffix, data=None): + """ + Perform an HTTP request to the Credly API. + + When authenticating with OAuth, a 401 response most likely means the cached + access token was revoked or expired early: refresh the token and retry once. + """ + try: + return super().perform_request(method, url_suffix, data) + except BadgeProviderError as exc: + if not (self.uses_oauth and exc.status_code == 401): + raise + + logger.warning("Credly rejected the OAuth access token, refreshing the token and retrying the request.") + self._get_oauth_token(force_refresh=True) + return super().perform_request(method, url_suffix, data) + def _get_headers(self): """ Returns the headers for making API requests to Credly. + Supports both OAuth Bearer Tokens and legacy Basic Auth API Keys. """ - return { + headers = { "Accept": "application/json", "Content-Type": "application/json", - "Authorization": f"Basic {self._build_authorization_token()}", } + if self.uses_oauth: + headers["Authorization"] = f"Bearer {self._get_oauth_token()}" + + elif self.api_key: + headers["Authorization"] = f"Basic {self._build_authorization_token()}" + + else: + raise CredlyError("No valid authentication credentials (OAuth or API Key) available for Credly.") + + return headers + @lru_cache def _build_authorization_token(self): """ - Build the authorization token for the Credly API. + Build the authorization token for the Credly API (Legacy). Returns: str: Authorization token. """ + if not self.api_key: + return "" return base64.b64encode(self.api_key.encode("ascii")).decode("ascii") def fetch_organization(self): @@ -165,6 +273,9 @@ def sync_organization_badge_templates(self, site_id): logger.error(f"Site with the id {site_id} does not exist!") raise + if not self.organization: + self.organization = self._get_organization(self.organization_id) + badge_templates_data = self.fetch_badge_templates() raw_badge_templates = badge_templates_data.get("data", []) diff --git a/credentials/apps/badges/exceptions.py b/credentials/apps/badges/exceptions.py index 5d908d671..00e52d462 100644 --- a/credentials/apps/badges/exceptions.py +++ b/credentials/apps/badges/exceptions.py @@ -18,4 +18,12 @@ class BadgesProcessingError(BadgesError): class BadgeProviderError(BadgesError): """ Exception raised for errors that occur during badge API client processing. + + Args: + message (str): Error description. + status_code (int, optional): HTTP status code of the failed provider response, if any. """ + + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code diff --git a/credentials/apps/badges/issuers.py b/credentials/apps/badges/issuers.py index 5997153a1..4d82bb6d4 100644 --- a/credentials/apps/badges/issuers.py +++ b/credentials/apps/badges/issuers.py @@ -19,6 +19,7 @@ ) from credentials.apps.badges.credly.api_client import CredlyAPIClient from credentials.apps.badges.credly.data import CredlyBadgeData +from credentials.apps.badges.credly.exceptions import CredlyError from credentials.apps.badges.exceptions import BadgeProviderError from credentials.apps.badges.models import ( AccredibleBadge, @@ -159,7 +160,7 @@ def issue_credly_badge(self, *, user_credential): try: credly_api = CredlyAPIClient(badge_template.organization.uuid) response = credly_api.issue_badge(credly_badge_data) - except BadgeProviderError: + except (BadgeProviderError, CredlyError): user_credential.state = "error" user_credential.save() raise @@ -180,7 +181,7 @@ def revoke_credly_badge(self, credential_id, user_credential): } try: response = credly_api.revoke_badge(user_credential.external_uuid, revoke_data) - except BadgeProviderError: + except (BadgeProviderError, CredlyError): user_credential.state = "error" user_credential.save() raise diff --git a/credentials/apps/badges/migrations/0003_credlyorganization_oauth_client_id_and_more.py b/credentials/apps/badges/migrations/0003_credlyorganization_oauth_client_id_and_more.py new file mode 100644 index 000000000..2536df64a --- /dev/null +++ b/credentials/apps/badges/migrations/0003_credlyorganization_oauth_client_id_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-11-04 13:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('badges', '0002_accredibleapiconfig_accrediblebadge_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='credlyorganization', + name='oauth_client_id', + field=models.CharField(blank=True, help_text='OAuth 2.0 Client ID for Credly Organization.', max_length=255), + ), + migrations.AddField( + model_name='credlyorganization', + name='oauth_client_secret', + field=models.CharField(blank=True, help_text='OAuth 2.0 Client Secret for Credly Organization.', max_length=255), + ), + ] diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py index 5fd76577d..96edbec4a 100644 --- a/credentials/apps/badges/models.py +++ b/credentials/apps/badges/models.py @@ -47,6 +47,16 @@ class CredlyOrganization(TimeStampedModel): blank=True, help_text=_("Verbose name for Credly Organization."), ) + oauth_client_id = models.CharField( + max_length=255, + blank=True, + help_text=_("OAuth 2.0 Client ID for Credly Organization."), + ) + oauth_client_secret = models.CharField( + max_length=255, + blank=True, + help_text=_("OAuth 2.0 Client Secret for Credly Organization."), + ) def __str__(self): return f"{self.name or self.uuid}" diff --git a/credentials/apps/badges/tests/test_admin.py b/credentials/apps/badges/tests/test_admin.py new file mode 100644 index 000000000..8c7be2a7c --- /dev/null +++ b/credentials/apps/badges/tests/test_admin.py @@ -0,0 +1,59 @@ +import uuid + +from django.contrib.admin.sites import AdminSite +from django.test import RequestFactory, TestCase, override_settings + +from credentials.apps.badges.admin import CredlyOrganizationAdmin +from credentials.apps.badges.models import CredlyOrganization + +PRECONFIGURED_UUID = str(uuid.uuid4()) + + +@override_settings(BADGES_CONFIG={"credly": {"ORGANIZATIONS": {PRECONFIGURED_UUID: "preconfigured-api-key"}}}) +class CredlyOrganizationAdminTestCase(TestCase): + """ + Pre-configured organizations must not expose editable credentials in the admin form. + """ + + def setUp(self): + self.admin = CredlyOrganizationAdmin(CredlyOrganization, AdminSite()) + self.request = RequestFactory().get("/") + self.preconfigured = CredlyOrganization.objects.create(uuid=PRECONFIGURED_UUID, name="preconfigured") + self.regular = CredlyOrganization.objects.create(uuid=uuid.uuid4(), name="regular", api_key="key") + + def _fieldset_fields(self, obj): + fields = [] + for _, options in self.admin.get_fieldsets(self.request, obj): + fields.extend(options["fields"]) + return fields + + def test_add_form_exposes_editable_credentials(self): + fields = self._fieldset_fields(obj=None) + + self.assertIn("api_key", fields) + self.assertIn("oauth_client_id", fields) + self.assertIn("oauth_client_secret", fields) + self.assertNotIn("api_key_hidden", fields) + self.assertEqual(self.admin.get_readonly_fields(self.request), ["name"]) + + def test_regular_organization_exposes_editable_credentials(self): + fields = self._fieldset_fields(self.regular) + + self.assertIn("api_key", fields) + self.assertNotIn("api_key_hidden", fields) + self.assertEqual(self.admin.get_readonly_fields(self.request, self.regular), ["name"]) + + def test_preconfigured_organization_hides_api_key(self): + fields = self._fieldset_fields(self.preconfigured) + + self.assertIn("api_key_hidden", fields) + self.assertNotIn("api_key", fields) + + def test_preconfigured_organization_credentials_are_readonly(self): + readonly_fields = self.admin.get_readonly_fields(self.request, self.preconfigured) + + self.assertCountEqual(readonly_fields, ["name", "api_key_hidden", "oauth_client_id", "oauth_client_secret"]) + + def test_api_key_hidden_display(self): + self.assertEqual(self.admin.api_key_hidden(self.preconfigured), "Pre-configured from the environment.") + self.assertEqual(self.admin.api_key_hidden(self.regular), "key") diff --git a/credentials/apps/badges/tests/test_admin_forms.py b/credentials/apps/badges/tests/test_admin_forms.py index 36effb2fd..9e83505ea 100644 --- a/credentials/apps/badges/tests/test_admin_forms.py +++ b/credentials/apps/badges/tests/test_admin_forms.py @@ -80,10 +80,7 @@ def test_clean_requirements_different_template(self): @override_settings(BADGES_CONFIG={"credly": {"ORGANIZATIONS": {}}}) def test_clean(self): form = CredlyOrganizationAdminForm() - form.cleaned_data = { - "uuid": "test_uuid", - "api_key": "test_api_key", - } + form.cleaned_data = {"uuid": "test_uuid", "api_key": "test_api_key"} with patch( "credentials.apps.badges.models.CredlyOrganization.get_preconfigured_organizations" @@ -96,7 +93,9 @@ def test_clean(self): form.clean() mock_get_orgs.assert_called_once() - mock_client.assert_called_once_with("test_uuid", "test_api_key") + mock_client.assert_called_once_with( + organization_id="test_uuid", api_key="test_api_key", oauth_client_id=None, oauth_client_secret=None + ) @override_settings(BADGES_CONFIG={"credly": {"ORGANIZATIONS": {"test_uuid": "test_api_key"}}}) def test_clean_with_configured_organization(self): @@ -117,7 +116,9 @@ def test_clean_with_configured_organization(self): form.clean() mock_get_orgs.assert_called_once() - mock_client.assert_called_once_with("test_uuid", "test_api_key") + mock_client.assert_called_once_with( + organization_id="test_uuid", api_key="test_api_key", oauth_client_id=None, oauth_client_secret=None + ) def test_clean_with_invalid_organization(self): form = CredlyOrganizationAdminForm() @@ -128,13 +129,23 @@ def test_clean_with_invalid_organization(self): with patch( "credentials.apps.badges.models.CredlyOrganization.get_preconfigured_organizations" - ) as mock_get_orgs: + ) as mock_get_orgs, patch( + "credentials.apps.badges.admin_forms.CredlyAPIClient.fetch_organization" + ) as mock_fetch_organization: mock_get_orgs.return_value = {"test_uuid": "test_org"} + mock_fetch_organization.side_effect = BadgeProviderError( + '{"data": {"message": "You specified an invalid authorization token."}} Status(401)', + status_code=401, + ) - with self.assertRaises(BadgeProviderError) as cm: + with self.assertRaises(forms.ValidationError) as cm: form.clean() - self.assertIn("You specified an invalid authorization token.", str(cm.exception)) + mock_fetch_organization.assert_called_once() + self.assertIn( + "Invalid OAuth credentials or API key. Credly rejected the authentication request.", + str(cm.exception), + ) def test_clean_cannot_provide_api_key_for_configured_organization(self): form = CredlyOrganizationAdminForm() @@ -153,12 +164,14 @@ def test_clean_cannot_provide_api_key_for_configured_organization(self): self.assertEqual( str(cm.exception), - '["You can\'t provide an API key for a configured organization."]', + '["You can\'t provide API keys or OAuth credentials for a pre-configured organization."]', ) def test_ensure_organization_exists(self): form = CredlyOrganizationAdminForm() api_client = MagicMock() + api_client.oauth_client_id = None + api_client.oauth_client_secret = None api_client.fetch_organization.return_value = {"data": {"org_id": "test_org_id"}} form.ensure_organization_exists(api_client) @@ -169,13 +182,56 @@ def test_ensure_organization_exists(self): def test_ensure_organization_exists_with_error(self): form = CredlyOrganizationAdminForm() api_client = MagicMock() + api_client.oauth_client_id = None + api_client.oauth_client_secret = None api_client.fetch_organization.side_effect = BadgeProviderError("API Error") - with self.assertRaises(BadgeProviderError) as cm: + with self.assertRaises(forms.ValidationError) as cm: form.ensure_organization_exists(api_client) api_client.fetch_organization.assert_called_once() - self.assertEqual(str(cm.exception), "API Error") + self.assertEqual( + str(cm.exception), + "['Error communicating with Credly API: API Error']", + ) + + def test_ensure_organization_exists_with_oauth(self): + form = CredlyOrganizationAdminForm() + api_client = MagicMock() + api_client.api_key = None + api_client.oauth_client_id = "oauth-client-id" + api_client.oauth_client_secret = "oauth-client-secret" + api_client.fetch_badge_templates.return_value = { + "data": [ + { + "owner": { + "name": "Test Credly Organization", + }, + }, + ], + } + + form.ensure_organization_exists(api_client) + + api_client.fetch_badge_templates.assert_called_once() + self.assertEqual(form.api_data, {"name": "Test Credly Organization"}) + + def test_ensure_organization_exists_with_oauth_error(self): + form = CredlyOrganizationAdminForm() + api_client = MagicMock() + api_client.api_key = None + api_client.oauth_client_id = "oauth-client-id" + api_client.oauth_client_secret = "oauth-client-secret" + api_client.fetch_badge_templates.side_effect = BadgeProviderError("API Error") + + with self.assertRaises(forms.ValidationError) as cm: + form.ensure_organization_exists(api_client) + + api_client.fetch_badge_templates.assert_called_once() + self.assertEqual( + str(cm.exception), + "['Error communicating with Credly API: API Error']", + ) class TestParentMixin(ParentMixin): diff --git a/credentials/apps/badges/tests/test_api_client.py b/credentials/apps/badges/tests/test_api_client.py index 50c173f92..e0270a344 100644 --- a/credentials/apps/badges/tests/test_api_client.py +++ b/credentials/apps/badges/tests/test_api_client.py @@ -1,14 +1,21 @@ +# pylint: disable=protected-access from unittest import mock +import requests +import responses from attrs import asdict +from django.core.cache import cache from django.test import TestCase from faker import Faker from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData from credentials.apps.badges.credly.api_client import CredlyAPIClient from credentials.apps.badges.credly.exceptions import CredlyError +from credentials.apps.badges.exceptions import BadgeProviderError from credentials.apps.badges.models import BadgeTemplate, CredlyOrganization +TOKEN_URL = "https://sandbox-api.credly.com/oauth/token" + class CredlyApiClientTestCase(TestCase): def setUp(self): @@ -129,3 +136,187 @@ def test_sync_organization_badge_templates(self): badge_templates = BadgeTemplate.objects.all() self.assertEqual(badge_templates.count(), 2) self.assertEqual(badge_templates[0].name, "Badge Template 1") + + +class CredlyApiClientOAuthTestCase(TestCase): + """ + OAuth 2.0 Client Credentials flow: token fetching, caching and 401 refresh. + + HTTP is intercepted with `responses`; the token cache is cleared around each test. + """ + + def setUp(self): + cache.clear() + self.organization = CredlyOrganization.objects.create( + uuid=Faker().uuid4(), + name="oauth_organization", + oauth_client_id="client-id", + oauth_client_secret="client-secret", + ) + self.api_client = CredlyAPIClient( + "test_organization_id", + oauth_client_id="client-id", + oauth_client_secret="client-secret", + ) + self.cache_key = "credly_oauth_access_token_client-id" + + def tearDown(self): + cache.clear() + + def test_init_loads_oauth_credentials_from_organization(self): + api_client = CredlyAPIClient(self.organization.uuid) + + self.assertEqual(api_client.oauth_client_id, "client-id") + self.assertEqual(api_client.oauth_client_secret, "client-secret") + self.assertEqual(api_client.organization, self.organization) + + def test_get_headers_uses_bearer_token_for_oauth(self): + cache.set(self.cache_key, "cached-token") + + headers = self.api_client._get_headers() + + self.assertEqual(headers["Authorization"], "Bearer cached-token") + + def test_get_headers_uses_basic_auth_for_api_key(self): + api_client = CredlyAPIClient("test_organization_id", api_key="test_api_key") + + headers = api_client._get_headers() + + self.assertEqual(headers["Authorization"], "Basic dGVzdF9hcGlfa2V5") + + def test_get_headers_without_credentials_raises(self): + api_client = CredlyAPIClient("test_organization_id", api_key="test_api_key") + api_client.api_key = None + + with self.assertRaises(CredlyError) as cm: + api_client._get_headers() + + self.assertIn("No valid authentication credentials", str(cm.exception)) + + @responses.activate + def test_get_oauth_token_requests_and_caches_token(self): + responses.add(responses.POST, TOKEN_URL, json={"access_token": "fresh-token", "expires_in": 7200}) + + token = self.api_client._get_oauth_token() + + self.assertEqual(token, "fresh-token") + self.assertEqual(cache.get(self.cache_key), "fresh-token") + self.assertEqual(len(responses.calls), 1) + request = responses.calls[0].request + self.assertIn("grant_type=client_credentials", request.body) + self.assertIn("scope=badge_templates+issued_badges", request.body) + self.assertTrue(request.headers["Authorization"].startswith("Basic ")) + + @responses.activate + def test_get_oauth_token_uses_cached_token(self): + cache.set(self.cache_key, "cached-token") + + token = self.api_client._get_oauth_token() + + self.assertEqual(token, "cached-token") + self.assertEqual(len(responses.calls), 0) + + @responses.activate + def test_get_oauth_token_force_refresh_bypasses_cache(self): + cache.set(self.cache_key, "stale-token") + responses.add(responses.POST, TOKEN_URL, json={"access_token": "fresh-token", "expires_in": 7200}) + + token = self.api_client._get_oauth_token(force_refresh=True) + + self.assertEqual(token, "fresh-token") + self.assertEqual(cache.get(self.cache_key), "fresh-token") + + def test_get_oauth_token_without_credentials_returns_none(self): + api_client = CredlyAPIClient("test_organization_id", api_key="test_api_key") + + self.assertIsNone(api_client._get_oauth_token()) + + @responses.activate + def test_get_oauth_token_missing_access_token_raises(self): + responses.add(responses.POST, TOKEN_URL, json={"token_type": "Bearer"}) + + with self.assertRaises(CredlyError) as cm: + self.api_client._get_oauth_token() + + self.assertIn("did not contain access_token", str(cm.exception)) + self.assertIsNone(cache.get(self.cache_key)) + + @responses.activate + def test_get_oauth_token_http_error_raises_credly_error(self): + responses.add(responses.POST, TOKEN_URL, json={"error": "invalid_client"}, status=401) + + with self.assertRaises(CredlyError) as cm: + self.api_client._get_oauth_token() + + self.assertIn("Failed to fetch OAuth token", str(cm.exception)) + + @responses.activate + def test_get_oauth_token_network_error_raises_credly_error(self): + responses.add(responses.POST, TOKEN_URL, body=requests.ConnectionError("boom")) + + with self.assertRaises(CredlyError) as cm: + self.api_client._get_oauth_token() + + self.assertIn("Failed to fetch OAuth token", str(cm.exception)) + + @responses.activate + def test_get_oauth_token_non_json_response_raises_credly_error(self): + responses.add(responses.POST, TOKEN_URL, body="Bad gateway", status=200) + + with self.assertRaises(CredlyError) as cm: + self.api_client._get_oauth_token() + + self.assertIn("non-JSON", str(cm.exception)) + self.assertIsNone(cache.get(self.cache_key)) + + @responses.activate + def test_perform_request_refreshes_token_and_retries_on_401(self): + cache.set(self.cache_key, "stale-token") + api_url = "https://sandbox-api.credly.com/v1/organizations/test_organization_id/badge_templates/" + responses.add(responses.GET, api_url, json={"message": "Unauthorized"}, status=401) + responses.add(responses.POST, TOKEN_URL, json={"access_token": "fresh-token", "expires_in": 7200}) + responses.add(responses.GET, api_url, json={"data": []}, status=200) + + result = self.api_client.perform_request("get", "badge_templates/") + + self.assertEqual(result, {"data": []}) + self.assertEqual(cache.get(self.cache_key), "fresh-token") + self.assertEqual(responses.calls[0].request.headers["Authorization"], "Bearer stale-token") + self.assertEqual(responses.calls[2].request.headers["Authorization"], "Bearer fresh-token") + + @responses.activate + def test_perform_request_retries_only_once_on_401(self): + cache.set(self.cache_key, "stale-token") + api_url = "https://sandbox-api.credly.com/v1/organizations/test_organization_id/badge_templates/" + responses.add(responses.GET, api_url, json={"message": "Unauthorized"}, status=401) + responses.add(responses.POST, TOKEN_URL, json={"access_token": "fresh-token", "expires_in": 7200}) + responses.add(responses.GET, api_url, json={"message": "Unauthorized"}, status=401) + + with self.assertRaises(BadgeProviderError) as cm: + self.api_client.perform_request("get", "badge_templates/") + + self.assertEqual(cm.exception.status_code, 401) + self.assertEqual(len(responses.calls), 3) + + @responses.activate + def test_perform_request_does_not_retry_on_401_with_api_key(self): + api_client = CredlyAPIClient("test_organization_id", api_key="test_api_key") + api_url = "https://sandbox-api.credly.com/v1/organizations/test_organization_id/badge_templates/" + responses.add(responses.GET, api_url, json={"message": "Unauthorized"}, status=401) + + with self.assertRaises(BadgeProviderError): + api_client.perform_request("get", "badge_templates/") + + self.assertEqual(len(responses.calls), 1) + + @responses.activate + def test_perform_request_does_not_retry_on_other_errors(self): + cache.set(self.cache_key, "token") + api_url = "https://sandbox-api.credly.com/v1/organizations/test_organization_id/badge_templates/" + responses.add(responses.GET, api_url, json={"message": "Server error"}, status=500) + + with self.assertRaises(BadgeProviderError) as cm: + self.api_client.perform_request("get", "badge_templates/") + + self.assertEqual(cm.exception.status_code, 500) + self.assertEqual(len(responses.calls), 1) diff --git a/credentials/conf/locale/en/LC_MESSAGES/django.po b/credentials/conf/locale/en/LC_MESSAGES/django.po index 1fafa911d..3151db16a 100644 --- a/credentials/conf/locale/en/LC_MESSAGES/django.po +++ b/credentials/conf/locale/en/LC_MESSAGES/django.po @@ -21,6 +21,28 @@ msgstr "" msgid "No rules specified." msgstr "" +#: apps/badges/admin.py +msgid "General Information" +msgstr "" + +#: apps/badges/admin.py +msgid "OAuth 2.0 Credentials" +msgstr "" + +#: apps/badges/admin.py +msgid "Authentication credentials for OAuth 2.0 Client Credentials flow." +msgstr "" + +#: apps/badges/admin.py +msgid "Legacy API Key Authentication" +msgstr "" + +#: apps/badges/admin.py +msgid "" +"Legacy Basic Auth API key. Use this only if OAuth 2.0 credentials are not " +"available." +msgstr "" + #: apps/badges/admin.py msgid "Badge templates were successfully updated." msgstr "" @@ -63,6 +85,7 @@ msgid "badge template" msgstr "" #: apps/badges/admin.py +#, python-brace-format msgid "Failed to sync groups for API config: {}. {}" msgstr "" @@ -71,7 +94,31 @@ msgid "Accredible groups were successfully updated." msgstr "" #: apps/badges/admin_forms.py -msgid "You can't provide an API key for a configured organization." +msgid "" +"You can't provide API keys or OAuth credentials for a pre-configured " +"organization." +msgstr "" + +#: apps/badges/admin_forms.py +msgid "" +"You must provide either OAuth credentials (Client ID & Client Secret) or a " +"legacy API Key." +msgstr "" + +#: apps/badges/admin_forms.py +msgid "" +"Both Client ID and Client Secret are required for OAuth authentication." +msgstr "" + +#: apps/badges/admin_forms.py +msgid "" +"Invalid OAuth credentials or API key. Credly rejected the authentication " +"request." +msgstr "" + +#: apps/badges/admin_forms.py +#, python-format +msgid "Error communicating with Credly API: %(error)s" msgstr "" #: apps/badges/admin_forms.py @@ -98,6 +145,14 @@ msgstr "" msgid "Verbose name for Credly Organization." msgstr "" +#: apps/badges/models.py +msgid "OAuth 2.0 Client ID for Credly Organization." +msgstr "" + +#: apps/badges/models.py +msgid "OAuth 2.0 Client Secret for Credly Organization." +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..e4e008552 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..ed5058cc8 100644 --- a/credentials/conf/locale/eo/LC_MESSAGES/django.po +++ b/credentials/conf/locale/eo/LC_MESSAGES/django.po @@ -21,6 +21,32 @@ msgstr "" msgid "No rules specified." msgstr "Nö rülés spéçïfïéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#" +#: apps/badges/admin.py +msgid "General Information" +msgstr "Généräl Ìnförmätïön Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#" + +#: apps/badges/admin.py +msgid "OAuth 2.0 Credentials" +msgstr "ÖÀüth 2.0 Çrédéntïäls Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" + +#: apps/badges/admin.py +msgid "Authentication credentials for OAuth 2.0 Client Credentials flow." +msgstr "" +"Àüthéntïçätïön çrédéntïäls för ÖÀüth 2.0 Çlïént Çrédéntïäls flöw. Ⱡ'σяєм " +"ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" + +#: apps/badges/admin.py +msgid "Legacy API Key Authentication" +msgstr "Légäçý ÀPÌ Kéý Àüthéntïçätïön Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#" + +#: apps/badges/admin.py +msgid "" +"Legacy Basic Auth API key. Use this only if OAuth 2.0 credentials are not " +"available." +msgstr "" +"Légäçý Bäsïç Àüth ÀPÌ kéý. Ûsé thïs önlý ïf ÖÀüth 2.0 çrédéntïäls äré nöt " +"äväïläßlé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" + #: apps/badges/admin.py msgid "Badge templates were successfully updated." msgstr "" @@ -78,6 +104,7 @@ msgid "badge template" msgstr "ßädgé témpläté Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#" #: apps/badges/admin.py +#, python-brace-format msgid "Failed to sync groups for API config: {}. {}" msgstr "" "Fäïléd tö sýnç gröüps för ÀPÌ çönfïg: {}. {} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " @@ -90,10 +117,42 @@ msgstr "" "¢σηѕє¢тєтυя #" #: apps/badges/admin_forms.py -msgid "You can't provide an API key for a configured organization." +msgid "" +"You can't provide API keys or OAuth credentials for a pre-configured " +"organization." msgstr "" -"Ýöü çän't prövïdé än ÀPÌ kéý för ä çönfïgüréd örgänïzätïön. Ⱡ'σяєм ιρѕυм " -"∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#" +"Ýöü çän't prövïdé ÀPÌ kéýs ör ÖÀüth çrédéntïäls för ä pré-çönfïgüréd " +"örgänïzätïön. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#" + +#: apps/badges/admin_forms.py +msgid "" +"You must provide either OAuth credentials (Client ID & Client Secret) or a " +"legacy API Key." +msgstr "" +"Ýöü müst prövïdé éïthér ÖÀüth çrédéntïäls (Çlïént ÌD & Çlïént Séçrét) ör ä " +"légäçý ÀPÌ Kéý. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" + +#: apps/badges/admin_forms.py +msgid "" +"Both Client ID and Client Secret are required for OAuth authentication." +msgstr "" +"Böth Çlïént ÌD änd Çlïént Séçrét äré réqüïréd för ÖÀüth äüthéntïçätïön. " +"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#" + +#: apps/badges/admin_forms.py +msgid "" +"Invalid OAuth credentials or API key. Credly rejected the authentication " +"request." +msgstr "" +"Ìnvälïd ÖÀüth çrédéntïäls ör ÀPÌ kéý. Çrédlý réjéçtéd thé äüthéntïçätïön " +"réqüést. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#" + +#: apps/badges/admin_forms.py +#, python-format +msgid "Error communicating with Credly API: %(error)s" +msgstr "" +"Érrör çömmünïçätïng wïth Çrédlý ÀPÌ: %(error)s Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя#" #: apps/badges/admin_forms.py msgid "All requirements must belong to the same template." @@ -129,6 +188,18 @@ msgstr "" "Vérßösé nämé för Çrédlý Örgänïzätïön. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " "¢σηѕє¢тєтυ#" +#: apps/badges/models.py +msgid "OAuth 2.0 Client ID for Credly Organization." +msgstr "" +"ÖÀüth 2.0 Çlïént ÌD för Çrédlý Örgänïzätïön. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " +"¢σηѕє¢тєтυя #" + +#: apps/badges/models.py +msgid "OAuth 2.0 Client Secret for Credly Organization." +msgstr "" +"ÖÀüth 2.0 Çlïént Séçrét för Çrédlý Örgänïzätïön. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт " +"αмєт, ¢σηѕє¢тєтυя α#" + #: 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..7afb0c1ca 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..e0846ea90 100644 --- a/credentials/conf/locale/rtl/LC_MESSAGES/django.po +++ b/credentials/conf/locale/rtl/LC_MESSAGES/django.po @@ -21,6 +21,30 @@ msgstr "" msgid "No rules specified." msgstr "Nø ɹnlǝs sdǝɔᴉɟᴉǝd." +#: apps/badges/admin.py +msgid "General Information" +msgstr "Ǥǝnǝɹɐl Ɨnɟøɹɯɐʇᴉøn" + +#: apps/badges/admin.py +msgid "OAuth 2.0 Credentials" +msgstr "ØȺnʇɥ 2.0 Ȼɹǝdǝnʇᴉɐls" + +#: apps/badges/admin.py +msgid "Authentication credentials for OAuth 2.0 Client Credentials flow." +msgstr "Ⱥnʇɥǝnʇᴉɔɐʇᴉøn ɔɹǝdǝnʇᴉɐls ɟøɹ ØȺnʇɥ 2.0 Ȼlᴉǝnʇ Ȼɹǝdǝnʇᴉɐls ɟløʍ." + +#: apps/badges/admin.py +msgid "Legacy API Key Authentication" +msgstr "Łǝƃɐɔʎ ȺⱣƗ Ꝁǝʎ Ⱥnʇɥǝnʇᴉɔɐʇᴉøn" + +#: apps/badges/admin.py +msgid "" +"Legacy Basic Auth API key. Use this only if OAuth 2.0 credentials are not " +"available." +msgstr "" +"Łǝƃɐɔʎ Ƀɐsᴉɔ Ⱥnʇɥ ȺⱣƗ ʞǝʎ. Ʉsǝ ʇɥᴉs ønlʎ ᴉɟ ØȺnʇɥ 2.0 ɔɹǝdǝnʇᴉɐls ɐɹǝ nøʇ " +"ɐʌɐᴉlɐblǝ." + #: apps/badges/admin.py msgid "Badge templates were successfully updated." msgstr "Ƀɐdƃǝ ʇǝɯdlɐʇǝs ʍǝɹǝ snɔɔǝssɟnllʎ nddɐʇǝd." @@ -68,6 +92,7 @@ msgid "badge template" msgstr "bɐdƃǝ ʇǝɯdlɐʇǝ" #: apps/badges/admin.py +#, python-brace-format msgid "Failed to sync groups for API config: {}. {}" msgstr "Fɐᴉlǝd ʇø sʎnɔ ƃɹønds ɟøɹ ȺⱣƗ ɔønɟᴉƃ: {}. {}" @@ -76,8 +101,39 @@ msgid "Accredible groups were successfully updated." msgstr "Ⱥɔɔɹǝdᴉblǝ ƃɹønds ʍǝɹǝ snɔɔǝssɟnllʎ nddɐʇǝd." #: apps/badges/admin_forms.py -msgid "You can't provide an API key for a configured organization." -msgstr "Ɏøn ɔɐn'ʇ dɹøʌᴉdǝ ɐn ȺⱣƗ ʞǝʎ ɟøɹ ɐ ɔønɟᴉƃnɹǝd øɹƃɐnᴉzɐʇᴉøn." +msgid "" +"You can't provide API keys or OAuth credentials for a pre-configured " +"organization." +msgstr "" +"Ɏøn ɔɐn'ʇ dɹøʌᴉdǝ ȺⱣƗ ʞǝʎs øɹ ØȺnʇɥ ɔɹǝdǝnʇᴉɐls ɟøɹ ɐ dɹǝ-ɔønɟᴉƃnɹǝd " +"øɹƃɐnᴉzɐʇᴉøn." + +#: apps/badges/admin_forms.py +msgid "" +"You must provide either OAuth credentials (Client ID & Client Secret) or a " +"legacy API Key." +msgstr "" +"Ɏøn ɯnsʇ dɹøʌᴉdǝ ǝᴉʇɥǝɹ ØȺnʇɥ ɔɹǝdǝnʇᴉɐls (Ȼlᴉǝnʇ ƗĐ & Ȼlᴉǝnʇ Sǝɔɹǝʇ) øɹ ɐ " +"lǝƃɐɔʎ ȺⱣƗ Ꝁǝʎ." + +#: apps/badges/admin_forms.py +msgid "" +"Both Client ID and Client Secret are required for OAuth authentication." +msgstr "" +"Ƀøʇɥ Ȼlᴉǝnʇ ƗĐ ɐnd Ȼlᴉǝnʇ Sǝɔɹǝʇ ɐɹǝ ɹǝbnᴉɹǝd ɟøɹ ØȺnʇɥ ɐnʇɥǝnʇᴉɔɐʇᴉøn." + +#: apps/badges/admin_forms.py +msgid "" +"Invalid OAuth credentials or API key. Credly rejected the authentication " +"request." +msgstr "" +"Ɨnʌɐlᴉd ØȺnʇɥ ɔɹǝdǝnʇᴉɐls øɹ ȺⱣƗ ʞǝʎ. Ȼɹǝdlʎ ɹǝɾǝɔʇǝd ʇɥǝ ɐnʇɥǝnʇᴉɔɐʇᴉøn " +"ɹǝbnǝsʇ." + +#: apps/badges/admin_forms.py +#, python-format +msgid "Error communicating with Credly API: %(error)s" +msgstr "Ɇɹɹøɹ ɔøɯɯnnᴉɔɐʇᴉnƃ ʍᴉʇɥ Ȼɹǝdlʎ ȺⱣƗ: %(error)s" #: apps/badges/admin_forms.py msgid "All requirements must belong to the same template." @@ -103,6 +159,14 @@ 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 "OAuth 2.0 Client ID for Credly Organization." +msgstr "ØȺnʇɥ 2.0 Ȼlᴉǝnʇ ƗĐ ɟøɹ Ȼɹǝdlʎ Øɹƃɐnᴉzɐʇᴉøn." + +#: apps/badges/models.py +msgid "OAuth 2.0 Client Secret for Credly Organization." +msgstr "ØȺnʇɥ 2.0 Ȼlᴉǝnʇ Sǝɔɹǝʇ ɟøɹ Ȼɹǝdlʎ Øɹƃɐnᴉzɐʇᴉøn." + #: 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