Skip to content

Commit 2fcd16e

Browse files
Tiago-Sallesigobranco
authored andcommitted
feat: add partner SSO authorization and account integration
Implements partner-based SSO with automatic authentication, account linking, and `external_user_id` automatic update. It includes: - A custom OAuth authorization view to handle partner SSO flows - An SSO registration model to link local users with partner identities - Django admin configuration for managing SSO registrations - Test coverage for the main SSO scenarios The authorization flow supports: - First-time users without an SSO registration (login + registration) - Users with an existing SSO registration (automatic authentication) - Users with an existing registration but a different external_user_id (login + account link update)
1 parent 653c215 commit 2fcd16e

File tree

10 files changed

+554
-20
lines changed

10 files changed

+554
-20
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.23 on 2025-12-11 13:23
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
import nau_openedx_extensions.custom_registration_form.models
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
('nau_openedx_extensions', '0009_partnerapiclient'),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='SSOPartnerIntegration',
20+
fields=[
21+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('external_user_id', models.CharField(max_length=128)),
23+
('partner_client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='nau_openedx_extensions.partnerapiclient')),
24+
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
25+
],
26+
options={
27+
'unique_together': {('user', 'external_user_id')},
28+
},
29+
),
30+
]

nau_openedx_extensions/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@
88
EnrollmentAllowedDomain,
99
EnrollmentAllowedList,
1010
)
11-
from nau_openedx_extensions.partner_integration.models import PartnerAPIClient # pylint: disable=unused-import
11+
from nau_openedx_extensions.partner_integration.models import ( # pylint: disable=unused-import
12+
PartnerAPIClient,
13+
SSOPartnerIntegration,
14+
)

nau_openedx_extensions/partner_integration/README.md

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Partner Integration Module
22

3-
The `partner_integration` module provides secure, scalable REST APIs for partner clients to interact with LMS data. It leverages the **Facade pattern** to encapsulate data extraction logic while enforcing strict security and validation rules.
3+
The `partner_integration` module provides secure, scalable REST APIs for partner clients to interact with LMS data. It leverages the **Facade pattern** to encapsulate data extraction logic while enforcing strict security and validation rules. It also includes the SSO implementation, where it has the possibility of linking partner's users's account.
44

55
## Features
66

@@ -308,3 +308,152 @@ The `partner_integration` module provides:
308308
- **Test coverage** for all critical use cases.
309309

310310
This module lays the foundation for future partner integrations and ensures compliance with organizational security policies.
311+
312+
## SSO integration
313+
314+
It classifies mainly in three states, in quick words:
315+
316+
- A user that comes from the partner platform and has no SSO register. It redirects to login page.
317+
- A user that comes from the partner platform and has SSO register. It redicts to the `redirect_uri` in the url.
318+
- A user that comes from the partner platform and has SSO register, but with a different `external_user_id`. It redirects to login page and after authenticating it updates the `external_user_id` on the SSO register.
319+
320+
### Important
321+
- All the flow respects our instance as the SSO authentication server.
322+
- All the features applied were created respecting the current available resources from Open Edx upstream, that is, it does not install new packages, nor uses new resources, it only implements the platform resources in a way that meets the SSO requirements.
323+
324+
## The key concepts
325+
326+
#### Partner JWT
327+
- Issued by the partner
328+
- Validated by ClientJWTAuthentication
329+
- Identifies which partner client is calling us
330+
331+
#### external_user_id
332+
- The user identifier on the partner system
333+
- Stored locally in SSOPartnerIntegration
334+
- Used as the primary lookup key during SSO
335+
336+
#### SSOPartnerIntegration
337+
- This model represents “this local user is linked to this partner user”. In other words "User X in our system corresponds to external user Y from partner Z"
338+
339+
## How it works
340+
341+
### `Applications`
342+
343+
The Open Edx Applications model, the one that manages the SSO applications. In this model we creates an application for each partner who wants to use our SSO integration.
344+
345+
### `PartnerAPIClient`
346+
347+
The model that represents each partner client that consumes our partner integration solutions (SSO and webservice).
348+
349+
### URL format
350+
```bash
351+
http://lms.local.nau.fccn.pt/nau-openedx-extensions/partner-integration/sso/authorize/
352+
?client_id=wFkgI0PDXXSYkrLwC4I3pe4t7lhwmWbjScJUULW3
353+
&redirect_uri=https://example.com
354+
&jwt_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMiIsInVzZXJuYW1lIjoiZ3RyYWluaW5nIiwiZXhwIjoxNzY3MDM3NzM5LCJpYXQiOjE3NjcwMzA1MzksImlzcyI6Imh0dHA6Ly9sbXMubG9jYWwubmF1LmZjY24ucHQvb2F1dGgyIiwiYXVkIjoib3BlbmVkeCJ9.5uf1q78l9jdKJ9n-Wu94IYPgtCRQknTCacHtjeGI0m0
355+
&external_user_id=123456789
356+
```
357+
#### client_id
358+
Setup via `Applications` model, the client id of an application register.
359+
360+
#### redirect_uri
361+
Dynamically setup, the partner changes the `redirect_uri` in order to redirect the user to the corresponding course he must to go.
362+
363+
#### jwt_token
364+
Obtained via webservice authentication, the `PartnerAPIClient` uses the authentication endpoint to obtain a valid jwt token.
365+
`https://lms.nau.edu.pt/nau-openedx-extensions/partner-integration/auth-token/`
366+
367+
#### external_user_id
368+
Dynamically setup, this is the information that identifies the user in the partner's platform, e.g. email, NIF, user_id or username. The user on our platform has no possibility to edit his register.
369+
370+
### `SSOPartnerIntegrations`
371+
372+
The model responsible for managing the SSO registers. Visible via Django admin.
373+
374+
## The entry point: `CustomAuthorizationView`
375+
376+
This view overrides the OAuth2 authorization process.
377+
378+
### Two important overridden methods
379+
`dispatch()` → decision & authentication
380+
`get()` → final redirect handling
381+
382+
### Methods descriptions:
383+
`dispatch()`: decides who the user is and what to do
384+
`get()`: decides where to send the user
385+
`handle_sso_registration()`: creates or updates the user SSO register.
386+
387+
388+
## Flow description
389+
1. Partner calls the endpoint
390+
391+
The partner redirects the user’s browser to this endpoint with:
392+
- client_id
393+
- jwt_token
394+
- external_user_id
395+
396+
2. Partner and application validation
397+
398+
Inside `dispatch()`:
399+
- The JWT is validated
400+
- If invalid → redirect to default partner URL:
401+
- The OAuth Application is fetched using `client_id`
402+
- If it doesn’t exist → redirect to default partner URL settings (e.g. NAU home page)
403+
404+
This ensures only known and active partners can use the flow.
405+
406+
## The three scenarios
407+
408+
### Scenario 1: User has no SSO registration
409+
No `SSOPartnerIntegration` exists for the given `external_user_id`:
410+
411+
#### What we do:
412+
- Redirect the user to the login page
413+
- After login, `handle_sso_registration()` is called
414+
- A new `SSOPartnerIntegration` is created
415+
- links the local user
416+
- stores the `external_user_id`
417+
- User is redirected back to the partner callback
418+
419+
#### Outcome:
420+
First-time SSO users get properly linked. From now on, future logins are seamless. This is first-time partner access.
421+
422+
### Scenario 2: User already has an SSO registration
423+
424+
#### A `SSOPartnerIntegration` exists for:
425+
- this `partner_client`
426+
- this `external_user_id`
427+
428+
#### What we do:
429+
- Authenticate the request
430+
- If the user is not logged in, login programmatically
431+
- Continue the OAuth flow and redirect
432+
433+
#### Outcome:
434+
- User is transparently logged in
435+
- No screens, no interaction
436+
- Clean SSO experience
437+
438+
This is the success path.
439+
440+
### Scenario 3: User exists, but `external_user_id` has changed
441+
442+
The user already has an SSO record, but the incoming `external_user_id` is different from the stored one.
443+
444+
#### This usually happens when:
445+
- Partner migrates users
446+
- Partner reissues accounts
447+
- External IDs change over time
448+
449+
#### What we do:
450+
- Force the user to authenticate again
451+
- Update the existing SSO record with the new `external_user_id`
452+
- Redirect back to the partner callback
453+
454+
#### Outcome:
455+
- The account link is repaired
456+
- No duplicate users are created
457+
- The system remains consistent
458+
459+
It corrects automatically a register that has changed its main information (`external_user_id`).

nau_openedx_extensions/partner_integration/admin.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.contrib import admin
66
from django.utils.crypto import get_random_string
77

8-
from .models import PartnerAPIClient
8+
from .models import PartnerAPIClient, SSOPartnerIntegration
99

1010

1111
class PartnerAPIClientForm(forms.ModelForm):
@@ -64,3 +64,9 @@ def get_form(self, request, obj=None, change=False, **kwargs):
6464
if not obj:
6565
current_form.base_fields["password"].initial = get_random_string(64)
6666
return current_form
67+
68+
69+
@admin.register(SSOPartnerIntegration)
70+
class SSOPartnerIntegrationAdmin(admin.ModelAdmin):
71+
"""Admin registration for SSOPartnerIntegration model"""
72+
list_display = ('partner_client', 'user', 'external_user_id')

nau_openedx_extensions/partner_integration/factories.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Factory classes for Data Extractor models."""
22
import factory
3+
from common.djangoapps.student.tests.factories import UserFactory
34

4-
from nau_openedx_extensions.partner_integration.models import PartnerAPIClient
5+
from nau_openedx_extensions.partner_integration.models import PartnerAPIClient, SSOPartnerIntegration
56

67

78
class PartnerAPIClientFactory(factory.django.DjangoModelFactory):
@@ -12,3 +13,14 @@ class Meta:
1213

1314
name = factory.Sequence(lambda n: f"partner-{n}")
1415
is_active = True
16+
17+
18+
class SSOPartnerIntegrationFactory(factory.django.DjangoModelFactory):
19+
"""Factory for SSOPartnerIntegration"""
20+
21+
class Meta:
22+
model = SSOPartnerIntegration
23+
24+
partner_client = factory.SubFactory(PartnerAPIClientFactory)
25+
user = factory.SubFactory(UserFactory)
26+
external_user_id = factory.Sequence(lambda n: f"external-user-{n}")

nau_openedx_extensions/partner_integration/models.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import uuid
55

6+
from django.contrib.auth import get_user_model
67
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
78
from django.db import models
89

@@ -102,7 +103,7 @@ class DummyProfile:
102103

103104
def __str__(self):
104105
"""String representation of the PartnerAPIClient."""
105-
return f"{self.name} ({self.client_id})"
106+
return f"{self.name} - {self.client_id}"
106107

107108
class Meta:
108109
app_label = "nau_openedx_extensions"
@@ -187,3 +188,21 @@ def check_value(field, value):
187188
except BaseException as e:
188189
logger.error(f"Removing invalid field {field} from scope: {e}")
189190
del scope[field]
191+
192+
193+
User = get_user_model()
194+
195+
196+
class SSOPartnerIntegration(models.Model):
197+
"""This model registers the users with completed SSO process"""
198+
partner_client = models.ForeignKey(PartnerAPIClient, on_delete=models.CASCADE, null=False)
199+
user = models.OneToOneField(User, on_delete=models.CASCADE, null=False)
200+
external_user_id = models.CharField(max_length=128, null=False, blank=False)
201+
202+
class Meta:
203+
app_label = "nau_openedx_extensions"
204+
unique_together = ('user', 'external_user_id',)
205+
206+
def __str__(self):
207+
"""String representation of the SSOPartnerIntegration."""
208+
return f"{self.partner_client.name} - {self.user.username} ({self.external_user_id})"

nau_openedx_extensions/partner_integration/oauth_authentication.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,33 +32,41 @@ def authenticate(self, request):
3232
"""
3333
Authenticate a client using JWT from Authorization header or cookies.
3434
"""
35-
raw_token = self.get_token_from_request(request)
36-
if raw_token is None:
37-
raise exceptions.AuthenticationFailed("Missing token")
35+
try:
36+
raw_token = self.get_token_from_request(request)
37+
if raw_token is None:
38+
raise exceptions.AuthenticationFailed("Missing token")
39+
40+
client = self.validate_token_data_and_return_client(raw_token)
41+
request.partner_client = client
3842

39-
token_data = self.get_validated_token(raw_token)
40-
client_id = token_data.get("user_id")
43+
return (AnonymousUser(), raw_token)
44+
except Exception as e:
45+
raise e
4146

47+
def validate_token_data_and_return_client(self, token):
48+
"""this method decodes the token and returns the `PartnerAPIClient`"""
4249
try:
50+
token_data = self.decode_token(token)
51+
client_id = token_data.get("user_id")
4352
client = PartnerAPIClient.objects.get(id=client_id, is_active=True)
53+
return client
4454
except PartnerAPIClient.DoesNotExist as e:
4555
logger.error("ClientJWTAuthentication: Invalid client ID provided in token.")
4656
raise exceptions.AuthenticationFailed("Invalid client") from e
47-
48-
request.partner_client = client
49-
50-
return (AnonymousUser(), raw_token)
57+
except Exception as e:
58+
raise e
5159

5260
@classmethod
53-
def get_validated_token(cls, raw_token):
61+
def decode_token(cls, raw_token):
5462
"""
5563
Decode JWT and validate using Open edX configured handler.
5664
"""
5765
try:
5866
payload = cls.jwt_decode_token(raw_token)
5967
except Exception as e:
60-
logger.error("ClientJWTAuthentication: Invalid token provided.")
61-
raise exceptions.AuthenticationFailed(f"Invalid token: {e}")
68+
logger.error("ClientJWTAuthentication: Invalid token provided.", exc_info=e)
69+
raise exceptions.AuthenticationFailed("Invalid token, renew the authentication and try again.")
6270

6371
return payload
6472

0 commit comments

Comments
 (0)