From c27b2f7b72204a7bea5c4b2c74ee4af4f899df10 Mon Sep 17 00:00:00 2001 From: luisfelipec95 Date: Tue, 4 Aug 2026 10:58:14 -0500 Subject: [PATCH 1/3] feat: add OAuth 2.0 Client Credentials support for Credly --- credentials/apps/badges/admin.py | 36 ++++++- credentials/apps/badges/admin_forms.py | 71 +++++++++++--- credentials/apps/badges/credly/api_client.py | 88 ++++++++++++++++-- credentials/apps/badges/issuers.py | 5 +- ...lyorganization_oauth_client_id_and_more.py | 23 +++++ credentials/apps/badges/models.py | 10 ++ .../apps/badges/tests/test_admin_forms.py | 71 +++++++++++--- .../conf/locale/en/LC_MESSAGES/django.po | 57 +++++++++++- .../conf/locale/eo/LC_MESSAGES/django.mo | Bin 42333 -> 44856 bytes .../conf/locale/eo/LC_MESSAGES/django.po | 77 ++++++++++++++- .../conf/locale/eo/LC_MESSAGES/djangojs.mo | Bin 543 -> 535 bytes .../conf/locale/rtl/LC_MESSAGES/django.mo | Bin 28074 -> 30008 bytes .../conf/locale/rtl/LC_MESSAGES/django.po | 68 +++++++++++++- .../conf/locale/rtl/LC_MESSAGES/djangojs.mo | Bin 504 -> 496 bytes 14 files changed, 464 insertions(+), 42 deletions(-) create mode 100644 credentials/apps/badges/migrations/0003_credlyorganization_oauth_client_id_and_more.py diff --git a/credentials/apps/badges/admin.py b/credentials/apps/badges/admin.py index 82dbf2663..73c33e816 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", ] 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/credly/api_client.py b/credentials/apps/badges/credly/api_client.py index 72ca64058..c8496419c 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.core.cache import cache from credentials.apps.badges.base_api_client import BaseBadgeProviderClient from credentials.apps.badges.credly.exceptions import CredlyError @@ -28,20 +29,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 +71,80 @@ def _get_organization(self, organization_id): except CredlyOrganization.DoesNotExist: raise CredlyError(f"CredlyOrganization with the uuid {organization_id} does not exist!") + def _get_oauth_token(self): + """ + Obtains a Bearer Access Token from Credly OAuth endpoint using Client Credentials grant. + """ + if not (self.oauth_client_id and self.oauth_client_secret): + return None + + cache_key = f"credly_oauth_access_token_{self.oauth_client_id}" + token = cache.get(cache_key) + + if not token: + token_url = urljoin(get_credly_api_base_url(settings), "/oauth/token") + + try: + payload = { + "grant_type": "client_credentials", + "scope": "badge_templates issued_badges", + } + + response = requests.post( + token_url, + data=payload, + auth=(self.oauth_client_id, self.oauth_client_secret), + headers={"Accept": "application/json"}, + timeout=10, + ) + response.raise_for_status() + data = response.json() + + token = data.get("access_token") + expires_in = data.get("expires_in", 7200) + + if not token: + raise CredlyError(f"Credly response did not contain access_token: {data}") + + cache.set(cache_key, token, timeout=max(expires_in - 60, 60)) + + except requests.RequestException as exc: + raise CredlyError(f"Failed to fetch OAuth token from Credly: {str(exc)}") from exc + + return token + 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.oauth_client_id and self.oauth_client_secret: + bearer_token = self._get_oauth_token() + headers["Authorization"] = f"Bearer {bearer_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 +236,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/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..843ad1dd6 --- /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-08-03 13:47 + +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, null=True), + ), + 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, null=True), + ), + ] 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_forms.py b/credentials/apps/badges/tests/test_admin_forms.py index 36effb2fd..d790d8197 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() @@ -131,10 +132,13 @@ def test_clean_with_invalid_organization(self): ) as mock_get_orgs: mock_get_orgs.return_value = {"test_uuid": "test_org"} - 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)) + 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 +157,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 +175,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/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 38f651d08c5ea49584d05ed3cd39859965b72b16..e4e008552e734512076b881552e3acd33ad1f199 100644 GIT binary patch delta 6295 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 HPUrsvl(Pzl delta 4644 zcmZA33s6>N9>?)N@_J#tfFz=V@}hE4QA0o_kc)CL1O&|sUI7yfQ&dR9MP6J@5lk`G z#MHDz!BR_K5$~pyDdH`6oY_j<3?0|qsd1gQbsM(*{@-(GcJ>*5@8>+{J?}Zsc`oO0 z;$FMEP3>&=+$~#$a+7$IogG9xoY?NFdqtLX7fGkS6#c0;x{Hj&Cm4mkFNjRQOiaUr zI0IWT38(iENx;4MB0j-k=;a|YLc}IXY=pC86?Vi1?2qTMFFrudCDK!*AN0T=48Z}I ziM??x2IBju=lc=|p_8YG6^GzJOva(O6g|1V>}DgK9Sul}#HE*U!ALBlo`ys4&*+Nn zxmp-{pchU+Zc1`70E&8ichXYZsGu+o^ z%-V2vjABPQ(g|`A87{ex47)r>9Xoesre44o_4{CS#tE2=6Oo&fou~~sjXK$GU?*%f zzwf}zwNm%9v7rYfpbkMH>V@ti$I9PPmz^t9SLen^?22Pe({LH}IjDDb3;C08`OqG7 zVgk@v(h+qC15nQ&g{)w)rLduQn2Fl8MW{bmhuZb~F$+J(P3TW+a~lN z-pPJ+!+H$DOUO^<8EOxDv-WhTqET-s1NHYSu#c|)jcn-Tt3^G5?+~LaQ&I0c1GRyt zQ7`lrj>GR!CszPJ(%((v!-8v3FSr49((T14Jc)YYf1&nxSH|6s>x(}dI>yPU2Noc0 zk)z1Gle?%t_!V_h^%^D;fkAi*7vLluKx;bUa?}Qwp!U#K)UmEH>kZhR`X#h!i<{Z# zgtt)ZUz-QqN4?-f49Dl_f+0M}^+ID%kB`SpOhTPIAEO?B6?Ij$=V_!39)qi~2yfvp z!Suf_w`(IrF5?sY3hg1r@>mxtGKG3A<~gx^FuNUhJknS#SD8u0d>_iyw%`%0#c@1+ z4&&eO48F(Jb^o+TkvjYpE3j@1{r?slq4fJ^JcTuw6m87vA8{3Rj~L#BScR{kCDvGW z8MujhGjb}K$y9$8zrpnw!$jGGmvIBW7H9nK8R~g+Z1KhkT5twC3KB$4;B~CR^@+x_ z-)lV20%v*wwdK>2jVIpsxQ%*X3Ma$Qa1sWj^8Ub;s15lAb^n@)#!KxE(nhh(O*0Po z5NU%9PB-pw3DUdr1=5T1AI!jT&T<&5@F0$z%+kak@oh|@d$g;+LG6h(jNev#j3rny z)!5&P1=?xZ)94nyxPz4#GhO5y-a^hPm0Z;aTW}6q=q7E*Je-MVk)KL$DtWjRbMaf8 zhjFv`p8)Hy9usNp2iS@UI%vB&Yau%xVHwVxYfQoxWW`BCrg4W4Q7XOQcyy`p8vY6OP7*juyS5HFRvw^s{bX8s4$t8~omoPR+ZpPGZyI+TTx2w`1@%tn z^RWv5g6nY(t*OCBxCbkW>Hl}x2;wNcqej%p^c+WHdWkV>%Tb5Mh2w|gNz{h@6(`}0 zQseD*2z6|Kz&~KfYTgHU9BHFOuv3Tf7}D9|X(RL-+`sQrt!vBq%xjX8fRmMLe&8R*46l3t^KN>e& zja(}=OwslKZ#FDW{IB<(v0htta6;}RkfVBI${t$H4{~dGv*8isa7QCL zVO!DR(xT(4rH-T$I?D079gEC6$3HpXcvd*pzn9i1pAqfS)kF)6;kPoy+-k-{glXbf zVhp=uA|@jv=jf3#Y%9~*2q5FycKnaJLToGeIAZS9as1reR#Oo{K(OKSBF0wJXt%9$U z7l^Kawvxxj0JCxf&yX5YV(uG{qe*u%pLh~oAy-L9qD8Oo_^Z}l71xqH@PYi!!f)`jzntU3A1tXbBoS;2kne+pjF z&;D~-v9sNO%9Act>GOKnKglk4wpZjtJMUeWXR+7kcXzf&6vhqktXXVrEKO_NXkFNN epz*9Vr}6kQYh#VI>iD+Ts-kCfu|KGu+Vj7~dT0^= 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 96571b0e3f97a27185de8f6f2ea9f6afe8bd24e3..7000a44bc4faf294dd0ac1294398b4a8d791c1f5 100644 GIT binary patch delta 56 xcmbQwGM#0Dh%Pe&1A`3^gMbGR%K)(_5N9zmFr)!#S0KI!WCIZc(?-v0i~zS~2DktK delta 64 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 aq~-^h1;QiAOY@2}jn-PqU}lM{{r><1RvcRZ delta 4647 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 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 71d10356e7cc079a8f8487e204ad3181ebe94d95..855ece954949f9ea737788986f14876706ba2d52 100644 GIT binary patch delta 56 xcmeyt{DFCbh%Pe&1A`3^gMbGR%K)(_5N9(oFr)!#Ss=ayWCIZc(?-wri~!na2O9tY delta 64 zcmeys{DXOdh$$-r1A`3^gFpZf%K&j85N9(oFysMgSs=azWCIZxFoDR8j_Vl#3@`^h From c695dbc70699ebb9fccd45548f73661168ad8fb6 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Wed, 4 Nov 2026 14:49:10 +0100 Subject: [PATCH 2/3] fix: harden Credly OAuth client and admin, add tests Follow-up to #3002 (OAuth 2.0 Client Credentials support for Credly). - Regenerate migration 0003 so it matches the model (no null=True) - Refresh the cached OAuth token and retry once when Credly returns 401 - Raise CredlyError on a non-JSON token response - Carry the HTTP status code on BadgeProviderError - Make admin fieldsets respect pre-configured organizations (masked API key, read-only OAuth fields) instead of the unused get_fields - Mock the Credly API call in the invalid-organization form test - Add tests for the OAuth client logic and the admin fieldsets --- credentials/apps/badges/admin.py | 32 ++- credentials/apps/badges/base_api_client.py | 4 +- credentials/apps/badges/credly/api_client.py | 96 ++++++--- credentials/apps/badges/exceptions.py | 8 + ...lyorganization_oauth_client_id_and_more.py | 6 +- credentials/apps/badges/tests/test_admin.py | 55 +++++ .../apps/badges/tests/test_admin_forms.py | 9 +- .../apps/badges/tests/test_api_client.py | 189 ++++++++++++++++++ 8 files changed, 353 insertions(+), 46 deletions(-) create mode 100644 credentials/apps/badges/tests/test_admin.py diff --git a/credentials/apps/badges/admin.py b/credentials/apps/badges/admin.py index 73c33e816..52f58082b 100644 --- a/credentials/apps/badges/admin.py +++ b/credentials/apps/badges/admin.py @@ -220,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/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 c8496419c..2d9c0c0e1 100644 --- a/credentials/apps/badges/credly/api_client.py +++ b/credentials/apps/badges/credly/api_client.py @@ -13,6 +13,7 @@ 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__) @@ -71,47 +72,81 @@ def _get_organization(self, organization_id): except CredlyOrganization.DoesNotExist: raise CredlyError(f"CredlyOrganization with the uuid {organization_id} does not exist!") - def _get_oauth_token(self): + @property + def uses_oauth(self): + """ + Whether the client authenticates with OAuth 2.0 Client Credentials. + """ + return bool(self.oauth_client_id and self.oauth_client_secret) + + @property + def _oauth_token_cache_key(self): + 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.oauth_client_id and self.oauth_client_secret): + if not self.uses_oauth: return None - cache_key = f"credly_oauth_access_token_{self.oauth_client_id}" - token = cache.get(cache_key) + token = None if force_refresh else cache.get(self._oauth_token_cache_key) + if token: + return token - if not token: - token_url = urljoin(get_credly_api_base_url(settings), "/oauth/token") + token_url = urljoin(get_credly_api_base_url(settings), "/oauth/token") + payload = { + "grant_type": "client_credentials", + "scope": "badge_templates issued_badges", + } - try: - 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 - response = requests.post( - token_url, - data=payload, - auth=(self.oauth_client_id, self.oauth_client_secret), - headers={"Accept": "application/json"}, - timeout=10, - ) - response.raise_for_status() - data = response.json() + 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") - expires_in = data.get("expires_in", 7200) + token = data.get("access_token") + if not token: + raise CredlyError(f"Credly response did not contain access_token: {data}") - 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)) - cache.set(cache_key, token, timeout=max(expires_in - 60, 60)) + return token - except requests.RequestException as exc: - raise CredlyError(f"Failed to fetch OAuth token from Credly: {str(exc)}") from exc + def perform_request(self, method, url_suffix, data=None): + """ + Perform an HTTP request to the Credly API. - return token + 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): """ @@ -123,9 +158,8 @@ def _get_headers(self): "Content-Type": "application/json", } - if self.oauth_client_id and self.oauth_client_secret: - bearer_token = self._get_oauth_token() - headers["Authorization"] = f"Bearer {bearer_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()}" 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/migrations/0003_credlyorganization_oauth_client_id_and_more.py b/credentials/apps/badges/migrations/0003_credlyorganization_oauth_client_id_and_more.py index 843ad1dd6..2536df64a 100644 --- 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 @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2026-08-03 13:47 +# Generated by Django 5.2.7 on 2026-11-04 13:09 from django.db import migrations, models @@ -13,11 +13,11 @@ class Migration(migrations.Migration): 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, null=True), + 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, null=True), + field=models.CharField(blank=True, help_text='OAuth 2.0 Client Secret for Credly Organization.', max_length=255), ), ] diff --git a/credentials/apps/badges/tests/test_admin.py b/credentials/apps/badges/tests/test_admin.py new file mode 100644 index 000000000..b54b81a30 --- /dev/null +++ b/credentials/apps/badges/tests/test_admin.py @@ -0,0 +1,55 @@ +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): + 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 d790d8197..9e83505ea 100644 --- a/credentials/apps/badges/tests/test_admin_forms.py +++ b/credentials/apps/badges/tests/test_admin_forms.py @@ -129,12 +129,19 @@ 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(forms.ValidationError) as cm: form.clean() + mock_fetch_organization.assert_called_once() self.assertIn( "Invalid OAuth credentials or API key. Credly rejected the authentication request.", str(cm.exception), diff --git a/credentials/apps/badges/tests/test_api_client.py b/credentials/apps/badges/tests/test_api_client.py index 50c173f92..836501c7e 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,185 @@ 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 of the Credly API client. + """ + + 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) From acfff55026347329ca1284b18aaaff6504145a09 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Wed, 4 Nov 2026 14:54:50 +0100 Subject: [PATCH 3/3] docs: add docstrings to OAuth client properties and test cases --- credentials/apps/badges/credly/api_client.py | 5 ++++- credentials/apps/badges/tests/test_admin.py | 4 ++++ credentials/apps/badges/tests/test_api_client.py | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/credentials/apps/badges/credly/api_client.py b/credentials/apps/badges/credly/api_client.py index 2d9c0c0e1..fe88402a3 100644 --- a/credentials/apps/badges/credly/api_client.py +++ b/credentials/apps/badges/credly/api_client.py @@ -75,12 +75,15 @@ def _get_organization(self, organization_id): @property def uses_oauth(self): """ - Whether the client authenticates with OAuth 2.0 Client Credentials. + 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): diff --git a/credentials/apps/badges/tests/test_admin.py b/credentials/apps/badges/tests/test_admin.py index b54b81a30..8c7be2a7c 100644 --- a/credentials/apps/badges/tests/test_admin.py +++ b/credentials/apps/badges/tests/test_admin.py @@ -11,6 +11,10 @@ @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("/") diff --git a/credentials/apps/badges/tests/test_api_client.py b/credentials/apps/badges/tests/test_api_client.py index 836501c7e..e0270a344 100644 --- a/credentials/apps/badges/tests/test_api_client.py +++ b/credentials/apps/badges/tests/test_api_client.py @@ -140,7 +140,9 @@ def test_sync_organization_badge_templates(self): class CredlyApiClientOAuthTestCase(TestCase): """ - OAuth 2.0 Client Credentials flow of the Credly API client. + 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):