-
Notifications
You must be signed in to change notification settings - Fork 83
feat: add OAuth 2.0 Client Credentials support for Credly #3002
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
|
|
||
| import requests # pylint: disable=unused-import | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: unused import directive can be removed now |
||
| from attrs import asdict | ||
| from django.core.cache import cache | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please sort imports alphabetically, so this new import goes after |
||
| from django.conf import settings | ||
| from django.contrib.sites.models import Site | ||
|
|
||
|
|
@@ -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}") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This type of exception is not handled in https://github.com/openedx/credentials/blob/master/credentials/apps/badges/issuers.py#L162 nor in revocation method https://github.com/openedx/credentials/blob/master/credentials/apps/badges/issuers.py#L183. |
||
|
|
||
| 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", []) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,18 @@ class CredlyOrganization(TimeStampedModel): | |
| blank=True, | ||
| help_text=_("Verbose name for Credly Organization."), | ||
| ) | ||
| oauth_client_id = models.CharField( | ||
| max_length=255, | ||
| blank=True, | ||
| null=True, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid |
||
| help_text=_("OAuth 2.0 Client ID for Credly Organization.") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please regenerate .po files with the newly added strings, so lint job can pass successfully. |
||
| ) | ||
| oauth_client_secret = models.CharField( | ||
| max_length=255, | ||
| blank=True, | ||
| null=True, | ||
| help_text=_("OAuth 2.0 Client Secret for Credly Organization.") | ||
| ) | ||
|
|
||
| def __str__(self): | ||
| return f"{self.name or self.uuid}" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just
401might match some other number in exception not specifically status code. Because in exception message status code is wrapped inStatus()it's better to check forif "Status(401)" in str(exc) ...