Skip to content
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

feat: adds support for OAuth 2 client credentials auth in xapi #9

Open
wants to merge 6 commits into
base: opencraft-release/nutmeg.2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions integrated_channels/xapi/admin/__init__.py
Expand Up @@ -19,6 +19,10 @@ class XAPILRSConfigurationAdmin(admin.ModelAdmin):
'version',
'key',
'secret',
'auth_method',
'auth_url',
'oauth_scope',
'plain_course_id_in_statements'
)

list_display = (
Expand Down
4 changes: 4 additions & 0 deletions integrated_channels/xapi/constants.py
Expand Up @@ -12,3 +12,7 @@
# Constants for edX grades
MIN_SCORE = 0
MAX_SCORE = 100

# Constants for xAPI Object ID types
OBJECT_ID_URI = 'uri'
OBJECT_ID_PLAIN = 'plain'
@@ -0,0 +1,28 @@
# Generated by Django 3.2.12 on 2023-09-01 07:26

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('xapi', '0007_auto_20220405_2311'),
]

operations = [
migrations.AddField(
model_name='xapilrsconfiguration',
name='auth_method',
field=models.CharField(choices=[('BASIC', 'HTTP Basic'), ('OAUTH2_CC', 'OAuth 2.0 Client Credentials')], default='BASIC', help_text='The Authentication Method to use when sending the xAPI data to the endpoint.', max_length=16, verbose_name='xAPI POST Authentication Method'),
),
migrations.AddField(
model_name='xapilrsconfiguration',
name='auth_url',
field=models.URLField(blank=True, help_text='URL to use for authentication. Eg., Token URL for OAuth', null=True),
),
migrations.AddField(
model_name='xapilrsconfiguration',
name='oauth_scope',
field=models.CharField(blank=True, help_text='The "scope" to pass for OAuth authentication.', max_length=255, null=True, verbose_name='OAuth scope'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2023-09-13 05:33

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('xapi', '0008_adds_auth_method_scope_and_url'),
]

operations = [
migrations.AddField(
model_name='xapilrsconfiguration',
name='plain_course_id_in_statements',
field=models.BooleanField(default=False, help_text='Uses the plain course ID (eg., course-v1:X+Y+Z) instead of the URI format in xAPI statements.', verbose_name='Use plain Course ID in xAPI Statements'),
),
]
68 changes: 68 additions & 0 deletions integrated_channels/xapi/models.py
Expand Up @@ -3,8 +3,12 @@
"""

import base64
from functools import cached_property

import requests

from django.contrib import auth
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _

Expand All @@ -16,6 +20,11 @@
User = auth.get_user_model()


class XAPIAuthMethods(models.TextChoices):
HTTP_BASIC = 'BASIC', _('HTTP Basic')
OAUTH2_CLIENT_CREDS = 'OAUTH2_CC', _('OAuth 2.0 Client Credentials')


class XAPILRSConfiguration(TimeStampedModel):
"""
xAPI LRS configurations.
Expand All @@ -39,6 +48,32 @@ class XAPILRSConfiguration(TimeStampedModel):
null=False,
help_text=_('Is this configuration active?'),
)
auth_method = models.CharField(
max_length=16,
verbose_name="xAPI POST Authentication Method",
choices=XAPIAuthMethods.choices,
default=XAPIAuthMethods.HTTP_BASIC,
help_text=_('The Authentication Method to use when sending the xAPI data to the endpoint.')
)
auth_url = models.URLField(
blank=True,
null=True,
help_text=_("URL to use for authentication. Eg., Token URL for OAuth")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this point to the LMS OAuth2 (e.g., localhost:18000/oauth2/access_token) or directly to the edx-enterprise service? It would be good to clarify this here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah. I think I have badly explained this PR. The OAuth URL would be from an external LRS service. For example, edCast uses <instance-hostname>/api/lrs/v1/xapi/oauth2/token.

)
oauth_scope = models.CharField(
max_length=255,
verbose_name=_('OAuth scope'),
blank=True,
null=True,
help_text=_('The "scope" to pass for OAuth authentication.')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What scopes are available here? Could we list them?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be specified by the LRS as well.

)
plain_course_id_in_statements = models.BooleanField(
default=False,
blank=False,
null=False,
verbose_name=_("Use plain Course ID in xAPI Statements"),
help_text=_('Uses the plain course ID (eg., course-v1:X+Y+Z) instead of the URI format in xAPI statements.')
)

class Meta:
app_label = 'xapi'
Expand All @@ -62,10 +97,43 @@ def authorization_header(self):
"""
Authorization header for authenticating requests to LRS.
"""
if self.auth_method == XAPIAuthMethods.OAUTH2_CLIENT_CREDS:
return f'Bearer {self.access_token}'

return 'Basic {}'.format(
base64.b64encode('{key}:{secret}'.format(key=self.key, secret=self.secret).encode()).decode()
)

def clean(self):
errors = {}
# Don't allow OAuth2 Client Credentials method to be set without auth_url, or oauth_scope
if self.auth_method == XAPIAuthMethods.OAUTH2_CLIENT_CREDS:
if not self.auth_url:
errors['auth_url'] = _("Authentication URL is required for the OAuth2 authentication method.")
if not self.oauth_scope:
errors['oauth_scope'] = _("OAuth scope is required for the OAuth2 authentication method.")

if errors:
raise ValidationError(errors)

@cached_property
def access_token(self):
"""
Gets the access token from the OAuth2 Authentication endpoint return it.
"""
if self.auth_method != XAPIAuthMethods.OAUTH2_CLIENT_CREDS:
raise RuntimeError("Access Token can be fetched only for OAuth2 Client Credentials authenication method.")

data = {
"grant_type": "client_credentials",
"scope": self.oauth_scope,
"client_id": self.key,
"client_secret": self.secret
}
response = requests.post(self.auth_url, data=data)
response.raise_for_status()
return response.json()["access_token"]


class XAPILearnerDataTransmissionAudit(LearnerDataTransmissionAudit):
"""
Expand Down
18 changes: 11 additions & 7 deletions integrated_channels/xapi/statements/base.py
Expand Up @@ -4,7 +4,7 @@

from tincan import Activity, ActivityDefinition, Agent, LanguageMap, Statement

from integrated_channels.xapi.constants import X_API_ACTIVITY_COURSE
from integrated_channels.xapi.constants import OBJECT_ID_PLAIN, X_API_ACTIVITY_COURSE


class EnterpriseStatement(Statement):
Expand Down Expand Up @@ -38,12 +38,13 @@ def get_actor(self, user, user_social_auth):
mbox='mailto:{email}'.format(email=user.email),
)

def get_object(self, domain, course_overview, object_type):
def get_object(self, domain, course_overview, object_type, object_id_type):
"""
Returns the object (activity) component of the Enterprise xAPI statement.
Arguments:
course_overview (CourseOverview): CourseOverview.
object_type (string): Object type for activity.
object_id_type (string): The format to use for the Object ID. OBJECT_ID_URI or OBJECT_ID_PLAIN
"""
name = (course_overview.display_name or '').encode("ascii", "ignore").decode('ascii')

Expand All @@ -53,11 +54,14 @@ def get_object(self, domain, course_overview, object_type):
if object_type is not None and object_type == 'course':
activity_id = course_overview.course_key

xapi_activity_id = 'https://{domain}/xapi/activities/{object_type}/{activity_id}'.format(
domain=domain,
object_type=object_type,
activity_id=activity_id
)
if object_id_type == OBJECT_ID_PLAIN:
xapi_activity_id = course_overview.id
else:
xapi_activity_id = 'https://{domain}/xapi/activities/{object_type}/{activity_id}'.format(
domain=domain,
object_type=object_type,
activity_id=activity_id
)

xapi_object_extensions = {}

Expand Down
16 changes: 14 additions & 2 deletions integrated_channels/xapi/statements/learner_course_completion.py
Expand Up @@ -13,7 +13,18 @@ class LearnerCourseCompletionStatement(EnterpriseStatement):
xAPI Statement to serialize data related to course completion.
"""

def __init__(self, site, user, user_social_auth, course_overview, course_grade, object_type, *args, **kwargs):
def __init__(
self,
site,
user,
user_social_auth,
course_overview,
course_grade,
object_type,
object_id_type,
*args,
**kwargs
):
"""
Initialize and populate statement with learner info and course info.

Expand All @@ -22,11 +33,12 @@ def __init__(self, site, user, user_social_auth, course_overview, course_grade,
user_social_auth (UserSocialAuth): UserSocialAuth object for learner
course_overview (CourseOverview): course overview object containing course details.
course_grade (CourseGrade): User grade in the course.
object_id_type (string): Either OBJECT_ID_PLAIN or OBJECT_ID_URI.
"""
kwargs.update(
actor=self.get_actor(user, user_social_auth),
verb=self.get_verb(),
object=self.get_object(site.domain, course_overview, object_type),
object=self.get_object(site.domain, course_overview, object_type, object_id_type),
result=self.get_result(course_grade),
)
super().__init__(*args, **kwargs)
Expand Down
Expand Up @@ -13,7 +13,7 @@ class LearnerCourseEnrollmentStatement(EnterpriseStatement):
xAPI statement to serialize data related to course registration.
"""

def __init__(self, site, user, user_social_auth, course_overview, object_type, *args, **kwargs):
def __init__(self, site, user, user_social_auth, course_overview, object_type, object_id_type, *args, **kwargs):
"""
Initialize and populate statement with learner info and course info.

Expand All @@ -22,11 +22,12 @@ def __init__(self, site, user, user_social_auth, course_overview, object_type, *
user_social_auth (UserSocialAuth): UserSocialAuth object of learner for the enterprise if learner has a
linked third party auth account
course_overview (CourseOverview): course overview object containing course details.
object_id_type (string): Either OBJECT_ID_PLAIN or OBJECT_ID_URI.
"""
kwargs.update(
actor=self.get_actor(user, user_social_auth),
verb=self.get_verb(),
object=self.get_object(site.domain, course_overview, object_type)
object=self.get_object(site.domain, course_overview, object_type, object_id_type)
)
super().__init__(*args, **kwargs)

Expand Down
5 changes: 4 additions & 1 deletion integrated_channels/xapi/utils.py
Expand Up @@ -7,6 +7,7 @@
from enterprise.tpa_pipeline import get_user_social_auth
from integrated_channels.exceptions import ClientError
from integrated_channels.xapi.client import EnterpriseXAPIClient
from integrated_channels.xapi.constants import OBJECT_ID_PLAIN, OBJECT_ID_URI
from integrated_channels.xapi.statements.learner_course_completion import LearnerCourseCompletionStatement
from integrated_channels.xapi.statements.learner_course_enrollment import LearnerCourseEnrollmentStatement

Expand Down Expand Up @@ -85,6 +86,7 @@ def send_course_enrollment_statement(lrs_configuration, user, course_overview, o
user_social_auth,
course_overview,
object_type,
OBJECT_ID_PLAIN if lrs_configuration.plain_course_id_in_statements else OBJECT_ID_URI
)

response_fields = _send_statement(
Expand Down Expand Up @@ -124,6 +126,7 @@ def send_course_completion_statement(lrs_configuration,
course_overview,
course_grade,
object_type,
OBJECT_ID_PLAIN if lrs_configuration.plain_course_id_in_statements else OBJECT_ID_URI
)

response_fields = _send_statement(
Expand All @@ -147,6 +150,6 @@ def is_success_response(response_fields):
Returns: Boolean
"""
success_response = False
if response_fields['status'] == 200:
if 200 <= response_fields['status'] <= 299:
success_response = True
return success_response
5 changes: 4 additions & 1 deletion test_utils/factories.py
Expand Up @@ -60,7 +60,7 @@
SAPSuccessFactorsGlobalConfiguration,
SapSuccessFactorsLearnerDataTransmissionAudit,
)
from integrated_channels.xapi.models import XAPILearnerDataTransmissionAudit, XAPILRSConfiguration
from integrated_channels.xapi.models import XAPIAuthMethods, XAPILearnerDataTransmissionAudit, XAPILRSConfiguration

FAKER = FakerFactory.create()
User = auth.get_user_model()
Expand Down Expand Up @@ -786,6 +786,9 @@ class Meta:
key = factory.LazyAttribute(lambda x: FAKER.slug())
secret = factory.LazyAttribute(lambda x: FAKER.uuid4())
active = True
auth_method = XAPIAuthMethods.HTTP_BASIC
auth_url = factory.LazyAttribute(lambda x: FAKER.url())
oauth_scope = factory.LazyAttribute(lambda x: FAKER.slug())


class XAPILearnerDataTransmissionAuditFactory(factory.django.DjangoModelFactory):
Expand Down
Expand Up @@ -287,10 +287,7 @@ def test_is_already_transmitted(self):

Command.is_already_transmitted(xapi_transmissions, user_id, course_id)

@mock.patch(
'integrated_channels.xapi.utils.is_success_response',
mock.MagicMock(return_value=False)
)
@mock.patch(MODULE_PATH + 'is_success_response', mock.MagicMock(return_value=False))
@mock.patch(MODULE_PATH + 'send_course_enrollment_statement')
def test_transmit_course_enrollments_transmit_fail_skip(self, mock_send_statement):
# pylint: disable=import-outside-toplevel
Expand Down