From ea20c5379fc03b2fb3b90094efe024a6f7b6c649 Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Thu, 9 Apr 2026 15:00:13 -0400 Subject: [PATCH 01/14] make staff and access attributes configurable for saml2 --- userportal/authentication.py | 18 ++++++++++++++---- userportal/settings/40-saml.py | 9 ++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/userportal/authentication.py b/userportal/authentication.py index 9bc37cb..20ac61e 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -1,15 +1,25 @@ from djangosaml2.backends import Saml2Backend from django.contrib.auth.backends import RemoteUserBackend +from django.conf import settings + class staffSaml2Backend(Saml2Backend): """This will add/remove the is_staff attribute from the user as appropriate.""" def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False): - if 'eduPersonAffiliation' in attributes: - if 'staff' in attributes['eduPersonAffiliation']: + # figure out if user is active (i.e. can login) + user.is_active = True + for attribute, value in settings.SAML_CONFIG['required_access_attributes']: + if attribute not in attributes or value not in attributes[attribute]: + user.is_active = False + + # figure out if user is staff + user.is_staff = False + for attribute, value in settings.SAML_CONFIG['staff_attributes']: + if attribute in attributes and value in attributes[attribute]: user.is_staff = True - else: - user.is_staff = False + break + user.first_name = attributes['givenName'][0] user.last_name = attributes['sn'][0] force_save = True diff --git a/userportal/settings/40-saml.py b/userportal/settings/40-saml.py index f55f36b..f566d97 100644 --- a/userportal/settings/40-saml.py +++ b/userportal/settings/40-saml.py @@ -64,4 +64,11 @@ 'key_file': '/opt/private.key', # private part 'cert_file': '/opt/public.cert', # public part }], -} \ No newline at end of file + # Use this to define if the user can login + 'required_access_attributes': [], + + # Use this to assign the staff role based on attributes returned by SAML + 'staff_attributes': [ + ('eduPersonAffiliation', 'staff') + ] +} From fe6cddc71635c7a3a60a4e74fd6018728d2fa132 Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 10 Apr 2026 16:36:04 -0400 Subject: [PATCH 02/14] moved djangosaml2 to a try except to define the class only if djangosaml2 is installed --- userportal/authentication.py | 48 +++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/userportal/authentication.py b/userportal/authentication.py index 20ac61e..03b314b 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -1,29 +1,33 @@ -from djangosaml2.backends import Saml2Backend from django.contrib.auth.backends import RemoteUserBackend - from django.conf import settings +try: + from djangosaml2.backends import Saml2Backend + + class staffSaml2Backend(Saml2Backend): + """This will add/remove the is_staff attribute from the user as appropriate.""" + def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False): + # figure out if user is active (i.e. can login) + user.is_active = True + for attribute, value in settings.SAML_CONFIG['required_access_attributes']: + if attribute not in attributes or value not in attributes[attribute]: + user.is_active = False + + # figure out if user is staff + user.is_staff = False + for attribute, value in settings.SAML_CONFIG['staff_attributes']: + if attribute in attributes and value in attributes[attribute]: + user.is_staff = True + break + + user.first_name = attributes['givenName'][0] + user.last_name = attributes['sn'][0] + force_save = True + return super()._update_user(user, attributes, attribute_mapping, force_save) + -class staffSaml2Backend(Saml2Backend): - """This will add/remove the is_staff attribute from the user as appropriate.""" - def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False): - # figure out if user is active (i.e. can login) - user.is_active = True - for attribute, value in settings.SAML_CONFIG['required_access_attributes']: - if attribute not in attributes or value not in attributes[attribute]: - user.is_active = False - - # figure out if user is staff - user.is_staff = False - for attribute, value in settings.SAML_CONFIG['staff_attributes']: - if attribute in attributes and value in attributes[attribute]: - user.is_staff = True - break - - user.first_name = attributes['givenName'][0] - user.last_name = attributes['sn'][0] - force_save = True - return super()._update_user(user, attributes, attribute_mapping, force_save) +except ImportError: + pass class staffRemoteUserBackend(RemoteUserBackend): From bdbf8d8b625f49955075e8ae67feb2cf3f0ca357 Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 10 Apr 2026 16:43:56 -0400 Subject: [PATCH 03/14] make is_staff configurable based on LDAP attributes --- userportal/authentication.py | 20 ++++++++++++++++++++ userportal/settings/10-base.py | 2 ++ 2 files changed, 22 insertions(+) diff --git a/userportal/authentication.py b/userportal/authentication.py index 9bc37cb..36b2026 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -1,5 +1,6 @@ from djangosaml2.backends import Saml2Backend from django.contrib.auth.backends import RemoteUserBackend +from django.conf import settings class staffSaml2Backend(Saml2Backend): @@ -32,3 +33,22 @@ def configure_user(self, request, user, created=True): user.is_staff = False user.save() return user + + +try: + from django_auth_ldap.backend import LDAPBackend + + class staffLdapBackend(LDAPBackend): + def get_or_build_user(self, username, ldap_user): + user, built = super().get_or_build_user(username, ldap_user) + + user.is_staff = False + for attribute, value in settings.LDAP_CONFIG['staff_attributes']: + if attribute in ldap_user.attrs.data and value in ldap_user.attrs.data[attribute]: + user.is_staff = True + + return user, built + + +except ImportError: + pass diff --git a/userportal/settings/10-base.py b/userportal/settings/10-base.py index 8eab4bc..e56a61b 100644 --- a/userportal/settings/10-base.py +++ b/userportal/settings/10-base.py @@ -152,6 +152,8 @@ LDAP_BASE_DN = 'dc=computecanada,dc=ca' +LDAP_CONFIG = {} + LOGIN_REDIRECT_URL = '/' # Set to DEMO to True to enable demo mode with anonymized data From 54e0cea0abe646189243b6772b3846a35d2b8c89 Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 17 Apr 2026 08:24:07 -0400 Subject: [PATCH 04/14] also add required_access_attributes to LDAP backend --- userportal/authentication.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/userportal/authentication.py b/userportal/authentication.py index 6d8e5b3..cfda28e 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -55,6 +55,12 @@ class staffLdapBackend(LDAPBackend): def get_or_build_user(self, username, ldap_user): user, built = super().get_or_build_user(username, ldap_user) + # figure out if user is active (i.e. can login) + user.is_active = True + for attribute, value in settings.LDAP_CONFIG['required_access_attributes']: + if attribute not in ldap_user.attrs.data or value not in ldap_user.attrs.data[attribute]: + user.is_active = False + user.is_staff = False for attribute, value in settings.LDAP_CONFIG['staff_attributes']: if attribute in ldap_user.attrs.data and value in ldap_user.attrs.data[attribute]: From ac2a9aca8ffdaa5cb61a24345bfc9c3842515bc1 Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 17 Apr 2026 08:28:26 -0400 Subject: [PATCH 05/14] no need to continue the loop if one attribute is missing --- userportal/authentication.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/userportal/authentication.py b/userportal/authentication.py index cfda28e..199a07c 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -12,6 +12,7 @@ def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_sa for attribute, value in settings.SAML_CONFIG['required_access_attributes']: if attribute not in attributes or value not in attributes[attribute]: user.is_active = False + break # figure out if user is staff user.is_staff = False @@ -60,6 +61,7 @@ def get_or_build_user(self, username, ldap_user): for attribute, value in settings.LDAP_CONFIG['required_access_attributes']: if attribute not in ldap_user.attrs.data or value not in ldap_user.attrs.data[attribute]: user.is_active = False + break user.is_staff = False for attribute, value in settings.LDAP_CONFIG['staff_attributes']: From 543ca798fc39318808fd5e8e8350826374b2705a Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Tue, 16 Jun 2026 15:36:37 -0400 Subject: [PATCH 06/14] first generation of code by antigravity --- docs/install.md | 3 +- docs/oidc.md | 56 ++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ requirements.txt | 2 ++ userportal/authentication.py | 63 ++++++++++++++++++++++++++++++++++ userportal/settings/42-oidc.py | 42 +++++++++++++++++++++++ userportal/urls.py | 6 ++++ 7 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 docs/oidc.md create mode 100644 userportal/settings/42-oidc.py diff --git a/docs/install.md b/docs/install.md index 999cf16..3e8b587 100644 --- a/docs/install.md +++ b/docs/install.md @@ -114,7 +114,8 @@ Listen 8000 ``` # Django Authentication -This portal is using the standard django authentication, multiple backends are supported, we are using SAML2 and the FreeIPA backend, on different clusters. Users with the is_staff attribute can access other users pages and the `top` module. +This portal is using standard Django authentication. Multiple backends are supported; we are using SAML2, OIDC/OAuth, and the FreeIPA/LDAP backend on different clusters. Users with the is_staff attribute can access other users' pages and the `top` module. + ## SAML2 For SAML2, certificates need to be generated and the metadata.xml need a little modification. diff --git a/docs/oidc.md b/docs/oidc.md new file mode 100644 index 0000000..1ce09a0 --- /dev/null +++ b/docs/oidc.md @@ -0,0 +1,56 @@ +# OpenID Connect (OIDC) / OAuth Authentication + +`mozilla-django-oidc` is supported as a lightweight OpenID Connect (OIDC) and OAuth 2.0 client authentication backend, similar to SAML2. + +## How it Works + +OIDC enables user authentication via an external Identity Provider (IdP) supporting OpenID Connect (such as Keycloak, Okta, Authentik, Dex, etc.). + +When a user tries to access a protected page, they are redirected to the OIDC provider's login page. Upon successful authentication, they are redirected back to the portal with an authorization code. The portal exchanges this code for an ID token and access token, and queries the userinfo endpoint to fetch user details (such as first name, last name, and role/group membership claims). + +The custom `staffOIDCBackend` then: +- Validates the tokens and claims. +- Generates a local username from the configured claim (e.g., `preferred_username`), cleaning the domain part if the claim is formatted as an email or principal name (e.g. `john@example.com` becomes `john`). +- Synchronizes the user's first and last names. +- Determines whether the user is active using `OIDC_REQUIRED_ACCESS_ATTRIBUTES`. +- Determines whether the user is a staff member using `OIDC_STAFF_ATTRIBUTES`. + +## Configuration + +To enable OIDC/OAuth authentication: + +1. **Enable the application and backend** in `userportal/settings/42-oidc.py` by uncommenting the setup lines: + + ```python + INSTALLED_APPS += ['mozilla_django_oidc'] + AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOIDCBackend'] + AUTHENTICATION_BACKENDS + LOGIN_URL = '/oidc/authenticate/' + ``` + +2. **Configure your OIDC Client Credentials and Endpoints** in `userportal/settings/42-oidc.py` (or a local overrides file like `99-local.py`): + + ```python + OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://your-idp.example.com/auth' + OIDC_OP_TOKEN_ENDPOINT = 'https://your-idp.example.com/token' + OIDC_OP_USERINFO_ENDPOINT = 'https://your-idp.example.com/userinfo' + OIDC_OP_JWKS_ENDPOINT = 'https://your-idp.example.com/jwks' + + OIDC_RP_CLIENT_ID = 'your-client-id' + OIDC_RP_CLIENT_SECRET = 'your-client-secret' + ``` + +3. **Map access and staff roles** based on the returned OIDC claims: + + ```python + # Only allow users with specific claims to log in + OIDC_REQUIRED_ACCESS_ATTRIBUTES = [ + ('email_verified', True) + ] + + # Automatically set user.is_staff based on OIDC claim values (e.g. group membership) + OIDC_STAFF_ATTRIBUTES = [ + ('groups', 'portal-staff') + ] + ``` + +For more details on available configurations, refer to the [mozilla-django-oidc documentation](https://mozilla-django-oidc.readthedocs.io/). diff --git a/mkdocs.yml b/mkdocs.yml index 684a89f..bc6cda7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,8 @@ nav: - 'Quotas': quotas.md - 'Quotas GPFS': quotasgpfs.md - 'CF Access': cfaccess.md + - 'OIDC / OAuth': oidc.md + theme: name: material diff --git a/requirements.txt b/requirements.txt index 22b764f..0050065 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,3 +45,5 @@ tzlocal==5.2 urllib3==2.6.3 xmlschema==2.5.1 PyJWT==2.12.1 +mozilla-django-oidc==5.0.2 + diff --git a/userportal/authentication.py b/userportal/authentication.py index 9bc37cb..1ee0710 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -32,3 +32,66 @@ def configure_user(self, request, user, created=True): user.is_staff = False user.save() return user + + +try: + from mozilla_django_oidc.auth import OIDCAuthenticationBackend + from django.conf import settings + + class staffOIDCBackend(OIDCAuthenticationBackend): + """This will add/remove the is_staff and is_active attributes from the user as appropriate based on OIDC claims.""" + def get_username(self, claims): + username = claims.get('preferred_username') + if not username: + username = claims.get('sub') + + if username and '@' in username: + username = username.split('@')[0] + return username + + def _update_user_attributes(self, user, claims): + user.is_staff = False + staff_attrs = getattr(settings, 'OIDC_STAFF_ATTRIBUTES', [('groups', 'staff')]) + for attribute, value in staff_attrs: + claim_val = claims.get(attribute) + if claim_val: + if isinstance(claim_val, list): + if value in claim_val: + user.is_staff = True + break + else: + if value == claim_val: + user.is_staff = True + break + + user.is_active = True + req_attrs = getattr(settings, 'OIDC_REQUIRED_ACCESS_ATTRIBUTES', []) + for attribute, value in req_attrs: + claim_val = claims.get(attribute) + if not claim_val: + user.is_active = False + break + if isinstance(claim_val, list): + if value not in claim_val: + user.is_active = False + break + else: + if value != claim_val: + user.is_active = False + break + + user.first_name = claims.get('given_name', claims.get('first_name', '')) + user.last_name = claims.get('family_name', claims.get('last_name', '')) + user.save() + return user + + def create_user(self, claims): + user = super().create_user(claims) + return self._update_user_attributes(user, claims) + + def update_user(self, user, claims): + user = super().update_user(user, claims) + return self._update_user_attributes(user, claims) +except ImportError: + pass + diff --git a/userportal/settings/42-oidc.py b/userportal/settings/42-oidc.py new file mode 100644 index 0000000..6c28f5e --- /dev/null +++ b/userportal/settings/42-oidc.py @@ -0,0 +1,42 @@ +# OIDC Settings using mozilla-django-oidc +# For documentation, see docs/oidc.md + +# To enable OIDC authentication, uncomment these lines: +# INSTALLED_APPS += ['mozilla_django_oidc'] +# MIDDLEWARE += ['mozilla_django_oidc.middleware.SessionRefresh'] # Optional, for token expiration/session refresh +# AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOIDCBackend'] + AUTHENTICATION_BACKENDS +# LOGIN_URL = '/oidc/authenticate/' +# LOGIN_REDIRECT_URL = '/' +# LOGOUT_REDIRECT_URL = '/' + +# OpenID Connect Provider configurations: +OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://your-idp.example.com/auth' +OIDC_OP_TOKEN_ENDPOINT = 'https://your-idp.example.com/token' +OIDC_OP_USERINFO_ENDPOINT = 'https://your-idp.example.com/userinfo' +OIDC_OP_JWKS_ENDPOINT = 'https://your-idp.example.com/jwks' + +OIDC_RP_CLIENT_ID = 'your-client-id' +OIDC_RP_CLIENT_SECRET = 'your-client-secret' + +# Algorithm for verifying JWT signatures (e.g. RS256, HS256) +OIDC_RP_SIGN_ALGO = 'RS256' + +# Custom scopes if needed +OIDC_RP_SCOPES = ['openid', 'email', 'profile'] + +# If set to True, a new Django user will be created if one does not exist +OIDC_CREATE_USER = True + +# Username claim to use for matching the LDAP username. +# Our custom staffOIDCBackend cleans the domain part if the claim contains an email or full principal name. +OIDC_USERNAME_CLAIM = 'preferred_username' + +# Use this to define if the user can login. +# List of tuples of (claim_name, expected_value). All must match. +OIDC_REQUIRED_ACCESS_ATTRIBUTES = [] + +# Use this to assign the staff role based on claims returned by OIDC. +# List of tuples of (claim_name, expected_value). If ANY matches, the user is_staff will be set to True. +OIDC_STAFF_ATTRIBUTES = [ + ('groups', 'staff') +] diff --git a/userportal/urls.py b/userportal/urls.py index 090e5e3..bc49ccd 100644 --- a/userportal/urls.py +++ b/userportal/urls.py @@ -65,6 +65,12 @@ path('test/', djangosaml2.views.EchoAttributesView.as_view()), ] +if "mozilla_django_oidc" in settings.INSTALLED_APPS: + urlpatterns += [ + path('oidc/', include('mozilla_django_oidc.urls')), + ] + + if 'jobstats' in settings.INSTALLED_APPS: urlpatterns.append(path('secure/jobstats/', include('jobstats.urls'))) From 2291e0b4d932da2ebe8afdeba6666fa6daa9b15c Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 19 Jun 2026 15:33:09 -0400 Subject: [PATCH 07/14] added middleware to handle the case of multiple proxies, needed so that oidc works correctly behind a proxy --- multipleproxy/middleware.py | 21 +++++++++++++++++++++ userportal/settings/42-oidc.py | 7 +++++++ 2 files changed, 28 insertions(+) create mode 100644 multipleproxy/middleware.py diff --git a/multipleproxy/middleware.py b/multipleproxy/middleware.py new file mode 100644 index 0000000..aba313c --- /dev/null +++ b/multipleproxy/middleware.py @@ -0,0 +1,21 @@ +class MultipleProxyMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + FORWARDED_FOR_FIELDS = [ + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_FORWARDED_HOST', + 'HTTP_X_FORWARDED_SERVER', + ] + + def __call__(self, request): + """ + Rewrites the proxy headers so that only the most + recent proxy is used. + """ + for field in self.FORWARDED_FOR_FIELDS: + if field in request.META: + if "," in request.META[field]: + parts = request.META[field].split(",") + request.META[field] = parts[-1].strip() + return self.get_response(request) diff --git a/userportal/settings/42-oidc.py b/userportal/settings/42-oidc.py index 6c28f5e..8a87e27 100644 --- a/userportal/settings/42-oidc.py +++ b/userportal/settings/42-oidc.py @@ -9,6 +9,13 @@ # LOGIN_REDIRECT_URL = '/' # LOGOUT_REDIRECT_URL = '/' +# if behind a proxy +# USE_X_FORWARDED_HOST = True +# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +# for the case of multiple proxies, this is needed +# MIDDLEWARE = ['multipleproxy.middleware.MultipleProxyMiddleware'] + MIDDLEWARE + + # OpenID Connect Provider configurations: OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://your-idp.example.com/auth' OIDC_OP_TOKEN_ENDPOINT = 'https://your-idp.example.com/token' From 68137ac6de9dba3f30951e9e129cb427f98e0c0d Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 19 Jun 2026 16:11:25 -0400 Subject: [PATCH 08/14] fix antigravity's mistakes --- userportal/settings/42-oidc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userportal/settings/42-oidc.py b/userportal/settings/42-oidc.py index 8a87e27..dcbbc1b 100644 --- a/userportal/settings/42-oidc.py +++ b/userportal/settings/42-oidc.py @@ -19,7 +19,7 @@ # OpenID Connect Provider configurations: OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://your-idp.example.com/auth' OIDC_OP_TOKEN_ENDPOINT = 'https://your-idp.example.com/token' -OIDC_OP_USERINFO_ENDPOINT = 'https://your-idp.example.com/userinfo' +OIDC_OP_USER_ENDPOINT = 'https://your-idp.example.com/userinfo' OIDC_OP_JWKS_ENDPOINT = 'https://your-idp.example.com/jwks' OIDC_RP_CLIENT_ID = 'your-client-id' @@ -29,7 +29,7 @@ OIDC_RP_SIGN_ALGO = 'RS256' # Custom scopes if needed -OIDC_RP_SCOPES = ['openid', 'email', 'profile'] +OIDC_RP_SCOPES = 'openid email profile' # If set to True, a new Django user will be created if one does not exist OIDC_CREATE_USER = True From 4569f1c45f56b99388e939760a7e2094d8839acb Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 19 Jun 2026 17:06:32 -0400 Subject: [PATCH 09/14] need to override filter_users_by_claims to use username, and verify claims since we have custom claims --- userportal/authentication.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/userportal/authentication.py b/userportal/authentication.py index 1ee0710..88cea23 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -39,6 +39,15 @@ def configure_user(self, request, user, created=True): from django.conf import settings class staffOIDCBackend(OIDCAuthenticationBackend): + """Claims verifications is done in _update_user_attributes""" + def verify_claims(self, claims): + return True + + """Get users that match by username""" + def filter_users_by_claims(self, claims): + username = self.get_username(claims) + return self.UserModel.objects.filter(username__iexact=username) + """This will add/remove the is_staff and is_active attributes from the user as appropriate based on OIDC claims.""" def get_username(self, claims): username = claims.get('preferred_username') @@ -93,5 +102,4 @@ def update_user(self, user, claims): user = super().update_user(user, claims) return self._update_user_attributes(user, claims) except ImportError: - pass - + pass \ No newline at end of file From 3efa99cace7b973e7acbbc098e543375ffc96fcd Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Tue, 23 Jun 2026 15:50:52 -0400 Subject: [PATCH 10/14] antigravity's pitch at implementing Oauth2 + JWT with OpenEdX --- docs/openedx.md | 59 +++++++++++++ mkdocs.yml | 1 + tests/tests.py | 72 ++++++++++++++++ userportal/authentication.py | 138 +++++++++++++++++++++++++++++- userportal/settings/43-openedx.py | 35 ++++++++ userportal/urls.py | 8 ++ userportal/views.py | 87 +++++++++++++++++++ 7 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 docs/openedx.md create mode 100644 userportal/settings/43-openedx.py create mode 100644 userportal/views.py diff --git a/docs/openedx.md b/docs/openedx.md new file mode 100644 index 0000000..d977a16 --- /dev/null +++ b/docs/openedx.md @@ -0,0 +1,59 @@ +# OpenEdX OAuth + JWT Authentication + +This backend allows users to authenticate against an OpenEdX LMS instance using OAuth 2.0 and JWT tokens. It is inspired by the Single Sign-On (SSO) implementation for Apache Superset in OpenEdX Aspects (`openedx_sso_security_manager.py`). + +## How it Works + +1. **Authorization Redirect**: When a user goes to `/openedx/login/`, they are redirected to the OpenEdX LMS authorization endpoint (`/oauth2/authorize/`). +2. **Authorization Code Grant**: After successful login at the LMS, the user is redirected back to the callback view (`/openedx/callback/`) with an authorization code. +3. **JWT Access Token Exchange**: The callback view sends a POST request to the LMS token endpoint (`/oauth2/access_token/`) specifying `token_type=jwt` as a parameter. OpenEdX LMS then issues a JSON Web Token (JWT) as the access token containing the user's claims. +4. **Token Decoding**: The custom `staffOpenEdxBackend` decodes the JWT payload. + - By default, it follows the `openedx_sso_security_manager` pattern of decoding with `verify_signature=False` (since the token was exchanged directly and securely server-to-server with the provider over HTTPS, validation of the signature is optional). + - Alternatively, it supports standard RS256/HS256 signature verification if `OPENEDX_VERIFY_SIGNATURE` is set to `True`. +5. **User Mapping (Similar to SAML2)**: Similar to the SAML2 implementation, the backend updates user fields and roles in `_update_user`: + - Synchronizes first name, last name, and email. + - Determines `is_staff` using `OPENEDX_STAFF_ATTRIBUTES` (e.g. checking if the `administrator` claim is `True`). + - Determines `is_superuser` if the `superuser` claim is `True`. + - Checks access restrictions via `OPENEDX_REQUIRED_ACCESS_ATTRIBUTES`. + +## Configuration + +To enable OpenEdX authentication: + +1. **Register the OAuth Application in OpenEdX**: + - Log into the LMS Django Admin panel. + - Go to **Django OAuth Toolkit** > **Applications**. + - Create a new application: + - **Client Type**: `Confidential` + - **Authorization Grant Type**: `Authorization Code` + - **Redirect URIs**: `https:///openedx/callback/` + - Retrieve the **Client ID** and **Client Secret**. + +2. **Enable the application and backend** in `userportal/settings/43-openedx.py` by uncommenting the setup lines: + + ```python + OPENEDX_AUTH_ENABLED = True + AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOpenEdxBackend'] + AUTHENTICATION_BACKENDS + LOGIN_URL = '/openedx/login/' + ``` + +3. **Configure your LMS Credentials and Endpoints** in `userportal/settings/43-openedx.py`: + + ```python + OPENEDX_LMS_URL = 'https://lms.example.com' + OPENEDX_CLIENT_ID = 'your-client-id' + OPENEDX_CLIENT_SECRET = 'your-client-secret' + ``` + +4. **Map access and staff roles** based on the returned claims: + + ```python + # Only allow users with specific claims to log in + OPENEDX_REQUIRED_ACCESS_ATTRIBUTES = [] + + # Automatically set user.is_staff based on OpenEdX claim values + # By default, the OpenEdX profile scope includes 'administrator': True for staff + OPENEDX_STAFF_ATTRIBUTES = [ + ('administrator', True) + ] + ``` diff --git a/mkdocs.yml b/mkdocs.yml index bc6cda7..57a6837 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,7 @@ nav: - 'Quotas GPFS': quotasgpfs.md - 'CF Access': cfaccess.md - 'OIDC / OAuth': oidc.md + - 'OpenEdX OAuth + JWT': openedx.md theme: diff --git a/tests/tests.py b/tests/tests.py index a4f5f7a..05832db 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -24,3 +24,75 @@ def setUp(self): def assertJSONKeys(self, response, keys): self.assertEqual(set(response.json().keys()), set(keys)) + + +from django.test import override_settings +from django.urls import reverse +from unittest.mock import patch, MagicMock + +class OpenEdxAuthTestCase(TestCase): + databases = '__all__' + + @override_settings( + OPENEDX_AUTH_ENABLED=True, + OPENEDX_LMS_URL='https://lms.example.com', + OPENEDX_CLIENT_ID='test-client-id', + OPENEDX_CLIENT_SECRET='test-client-secret', + OPENEDX_VERIFY_SIGNATURE=False, + ) + def test_login_redirect(self): + client = Client() + response = client.get(reverse('openedx_login')) + self.assertEqual(response.status_code, 302) + self.assertTrue(response['Location'].startswith('https://lms.example.com/oauth2/authorize/')) + self.assertIn('client_id=test-client-id', response['Location']) + self.assertIn('response_type=code', response['Location']) + + @override_settings( + OPENEDX_AUTH_ENABLED=True, + OPENEDX_LMS_URL='https://lms.example.com', + OPENEDX_CLIENT_ID='test-client-id', + OPENEDX_CLIENT_SECRET='test-client-secret', + OPENEDX_VERIFY_SIGNATURE=False, + OPENEDX_STAFF_ATTRIBUTES=[('administrator', True)], + ) + @patch('requests.post') + @patch('jwt.decode') + def test_callback_success(self, mock_jwt_decode, mock_post): + # Mock requests.post for token exchange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {'access_token': 'fake-jwt-token'} + mock_post.return_value = mock_response + + # Mock jwt.decode for payload extraction + mock_jwt_decode.return_value = { + 'username': 'openedxuser', + 'email': 'openedxuser@example.com', + 'given_name': 'Open', + 'family_name': 'EdX', + 'administrator': True, + 'superuser': False, + } + + # Set session state + client = Client() + session = client.session + session['openedx_oauth_state'] = 'teststate' + session.save() + + response = client.get(reverse('openedx_callback'), {'state': 'teststate', 'code': 'testcode'}) + + # Verify redirect to home + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/') + + # Verify user was created + User = get_user_model() + user = User.objects.get(username='openedxuser') + self.assertEqual(user.email, 'openedxuser@example.com') + self.assertEqual(user.first_name, 'Open') + self.assertEqual(user.last_name, 'EdX') + self.assertTrue(user.is_staff) + self.assertFalse(user.is_superuser) + diff --git a/userportal/authentication.py b/userportal/authentication.py index 88cea23..54a6bb2 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -102,4 +102,140 @@ def update_user(self, user, claims): user = super().update_user(user, claims) return self._update_user_attributes(user, claims) except ImportError: - pass \ No newline at end of file + pass + + +class staffOpenEdxBackend(RemoteUserBackend): + """Authentication backend for OpenEdX OAuth2 + JWT tokens, using RemoteUserBackend.""" + + @property + def create_unknown_user(self): + return getattr(settings, 'OPENEDX_CREATE_UNKNOWN_USER', True) + + def verify_token(self, token): + import jwt + try: + verify_signature = getattr(settings, 'OPENEDX_VERIFY_SIGNATURE', False) + if verify_signature: + from jwt import PyJWKClient + jwks_endpoint = getattr(settings, 'OPENEDX_JWKS_ENDPOINT', None) + if not jwks_endpoint and hasattr(settings, 'OPENEDX_LMS_URL'): + jwks_endpoint = f"{settings.OPENEDX_LMS_URL.rstrip('/')}/oauth2/jwks/" + + algorithms = [getattr(settings, 'OPENEDX_SIGN_ALGO', 'RS256')] + audience = getattr(settings, 'OPENEDX_CLIENT_ID', None) + + if jwks_endpoint and 'RS256' in algorithms: + jwk_client = PyJWKClient(jwks_endpoint) + signing_key = jwk_client.get_signing_key_from_jwt(token) + key = signing_key.key + else: + key = getattr(settings, 'OPENEDX_SECRET_KEY', getattr(settings, 'OPENEDX_CLIENT_SECRET', None)) + + if not key: + raise ValueError("No signature verification key or JWKS endpoint configured.") + + payload = jwt.decode( + token, + key, + algorithms=algorithms, + audience=audience, + options={"verify_aud": bool(audience)} + ) + else: + # Decode user info without verification, inspired by openedx_sso_security_manager in tutor-contrib-aspects + payload = jwt.decode( + token, + options={"verify_signature": False} + ) + return payload + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to decode/verify OpenEdX JWT token: {e}") + return None + + def verify_claims(self, claims): + req_attrs = getattr(settings, 'OPENEDX_REQUIRED_ACCESS_ATTRIBUTES', []) + for attribute, value in req_attrs: + claim_val = claims.get(attribute) + if claim_val is None: + return False + if isinstance(claim_val, list): + if value not in claim_val: + return False + else: + if value != claim_val: + return False + return True + + def clean_username(self, username): + if username and '@' in username: + username = username.split('@')[0] + return username + + def configure_user(self, request, user, created=True): + """Configure user based on the decoded claims stored in request or backend.""" + claims = getattr(request, '_openedx_claims', getattr(self, '_temp_claims', None)) + if not claims: + return user + + user.first_name = claims.get('given_name', claims.get('first_name', claims.get('name', ''))) + user.last_name = claims.get('family_name', claims.get('last_name', '')) + user.email = claims.get('email', '') + + # Set staff status + user.is_staff = False + staff_attrs = getattr(settings, 'OPENEDX_STAFF_ATTRIBUTES', [('administrator', True)]) + for attribute, value in staff_attrs: + claim_val = claims.get(attribute) + if claim_val is not None: + if isinstance(claim_val, list): + if value in claim_val: + user.is_staff = True + break + else: + if value == claim_val: + user.is_staff = True + break + + # Set superuser status + if claims.get('superuser') is True: + user.is_superuser = True + + user.save() + return user + + def authenticate(self, request, remote_user=None, token=None, **kwargs): + """ + Authenticate with either remote_user (standard RemoteUserBackend) + or OpenEdX JWT token. + """ + if token: + claims = self.verify_token(token) + if not claims: + return None + if not self.verify_claims(claims): + return None + + username = claims.get('preferred_username') or claims.get('username') + if not username: + username = claims.get('sub') + + if not username: + return None + + # Store claims temporarily where configure_user can access them + self._temp_claims = claims + if request: + request._openedx_claims = claims + + user = super().authenticate(request, remote_user=username) + + self._temp_claims = None + if request and hasattr(request, '_openedx_claims'): + delattr(request, '_openedx_claims') + + return user + + return super().authenticate(request, remote_user=remote_user) \ No newline at end of file diff --git a/userportal/settings/43-openedx.py b/userportal/settings/43-openedx.py new file mode 100644 index 0000000..7ea2578 --- /dev/null +++ b/userportal/settings/43-openedx.py @@ -0,0 +1,35 @@ +# OpenEdX OAuth + JWT Settings +# For documentation, see docs/openedx.md + +# To enable OpenEdX authentication, uncomment these lines: +# OPENEDX_AUTH_ENABLED = True +# AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOpenEdxBackend'] + AUTHENTICATION_BACKENDS +# LOGIN_URL = '/openedx/login/' +# LOGIN_REDIRECT_URL = '/' +# LOGOUT_REDIRECT_URL = '/' + +# OpenEdX LMS Provider configurations: +OPENEDX_LMS_URL = 'https://lms.example.com' +OPENEDX_CLIENT_ID = 'your-client-id' +OPENEDX_CLIENT_SECRET = 'your-client-secret' + +# JWT signature verification options +# By default, verify_signature can be False when using server-to-server token exchange, +# similar to openedx_sso_security_manager in tutor-contrib-aspects. +OPENEDX_VERIFY_SIGNATURE = False +OPENEDX_SIGN_ALGO = 'RS256' + +# Scope to request from OpenEdX +OPENEDX_SCOPE = 'read write email profile' + +# Create Django user if they don't exist (similar to SAML_CREATE_UNKNOWN_USER) +OPENEDX_CREATE_UNKNOWN_USER = True + +# List of tuples of (claim_name, expected_value) for access requirements (must all match) +OPENEDX_REQUIRED_ACCESS_ATTRIBUTES = [] + +# List of tuples of (claim_name, expected_value). If ANY matches, user.is_staff will be True. +# Under OpenEdX profile scope, 'administrator' is set to True for staff users. +OPENEDX_STAFF_ATTRIBUTES = [ + ('administrator', True) +] diff --git a/userportal/urls.py b/userportal/urls.py index bc49ccd..135c965 100644 --- a/userportal/urls.py +++ b/userportal/urls.py @@ -70,6 +70,14 @@ path('oidc/', include('mozilla_django_oidc.urls')), ] +if getattr(settings, 'OPENEDX_AUTH_ENABLED', False): + from userportal.views import OpenEdxLoginView, OpenEdxCallbackView + urlpatterns += [ + path('openedx/login/', OpenEdxLoginView.as_view(), name='openedx_login'), + path('openedx/callback/', OpenEdxCallbackView.as_view(), name='openedx_callback'), + ] + + if 'jobstats' in settings.INSTALLED_APPS: urlpatterns.append(path('secure/jobstats/', include('jobstats.urls'))) diff --git a/userportal/views.py b/userportal/views.py new file mode 100644 index 0000000..5ea2c71 --- /dev/null +++ b/userportal/views.py @@ -0,0 +1,87 @@ +import logging +import secrets +from django.conf import settings +from django.contrib.auth import authenticate, login +from django.http import HttpResponseBadRequest, HttpResponseRedirect +from django.urls import reverse +from django.views import View +import requests + +logger = logging.getLogger(__name__) + + +class OpenEdxLoginView(View): + """View to redirect users to OpenEdX LMS for authorization.""" + + def get(self, request, *args, **kwargs): + # Generate a random state to prevent CSRF + state = secrets.token_urlsafe(32) + request.session['openedx_oauth_state'] = state + + lms_url = settings.OPENEDX_LMS_URL.rstrip('/') + auth_url = f"{lms_url}/oauth2/authorize/" + + # Build authorization redirect URL + redirect_uri = request.build_absolute_uri(reverse('openedx_callback')) + + params = { + 'response_type': 'code', + 'client_id': settings.OPENEDX_CLIENT_ID, + 'redirect_uri': redirect_uri, + 'scope': getattr(settings, 'OPENEDX_SCOPE', 'read write email profile'), + 'state': state, + } + + url_with_params = f"{auth_url}?{requests.compat.urlencode(params)}" + return HttpResponseRedirect(url_with_params) + + +class OpenEdxCallbackView(View): + """View to handle OpenEdX OAuth callback and authenticate the user.""" + + def get(self, request, *args, **kwargs): + # Validate state + state = request.GET.get('state') + session_state = request.session.pop('openedx_oauth_state', None) + if not state or state != session_state: + return HttpResponseBadRequest("Invalid state parameter.") + + code = request.GET.get('code') + if not code: + return HttpResponseBadRequest("Missing authorization code.") + + lms_url = settings.OPENEDX_LMS_URL.rstrip('/') + token_url = f"{lms_url}/oauth2/access_token/" + + redirect_uri = request.build_absolute_uri(reverse('openedx_callback')) + + # Exchange authorization code for JWT access token + data = { + 'grant_type': 'authorization_code', + 'client_id': settings.OPENEDX_CLIENT_ID, + 'client_secret': settings.OPENEDX_CLIENT_SECRET, + 'code': code, + 'redirect_uri': redirect_uri, + 'token_type': 'jwt', # OpenEdX specific parameter to request a JWT + } + + try: + response = requests.post(token_url, data=data, timeout=10) + response.raise_for_status() + res_data = response.json() + except Exception as e: + logger.error(f"Failed to exchange code for OpenEdX token: {e}") + return HttpResponseBadRequest("Token exchange failed.") + + access_token = res_data.get('access_token') + if not access_token: + return HttpResponseBadRequest("No access token returned from OpenEdX.") + + # Authenticate user with the token using the custom backend + user = authenticate(request, token=access_token) + if user is not None: + login(request, user) + redirect_url = getattr(settings, 'LOGIN_REDIRECT_URL', '/') + return HttpResponseRedirect(redirect_url) + else: + return HttpResponseBadRequest("Authentication failed.") From df6f6859b98223e69561b868809db283889c8d88 Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 3 Jul 2026 09:14:41 -0400 Subject: [PATCH 11/14] switch from RemoteUserBackend to ModelBackend --- userportal/authentication.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/userportal/authentication.py b/userportal/authentication.py index 54a6bb2..5ab87b1 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -1,5 +1,5 @@ from djangosaml2.backends import Saml2Backend -from django.contrib.auth.backends import RemoteUserBackend +from django.contrib.auth.backends import ModelBackend, RemoteUserBackend class staffSaml2Backend(Saml2Backend): @@ -105,8 +105,13 @@ def update_user(self, user, claims): pass -class staffOpenEdxBackend(RemoteUserBackend): - """Authentication backend for OpenEdX OAuth2 + JWT tokens, using RemoteUserBackend.""" +class staffOpenEdxBackend(ModelBackend): + """Authentication backend for OpenEdX OAuth2 + JWT tokens, using ModelBackend.""" + + @property + def UserModel(self): + from django.contrib.auth import get_user_model + return get_user_model() @property def create_unknown_user(self): @@ -208,8 +213,7 @@ def configure_user(self, request, user, created=True): def authenticate(self, request, remote_user=None, token=None, **kwargs): """ - Authenticate with either remote_user (standard RemoteUserBackend) - or OpenEdX JWT token. + Authenticate with OpenEdX JWT token. """ if token: claims = self.verify_token(token) @@ -230,12 +234,25 @@ def authenticate(self, request, remote_user=None, token=None, **kwargs): if request: request._openedx_claims = claims - user = super().authenticate(request, remote_user=username) + # Find or create user + username = self.clean_username(username) + if self.create_unknown_user: + user, created = self.UserModel._default_manager.get_or_create(**{ + self.UserModel.USERNAME_FIELD: username + }) + user = self.configure_user(request, user, created=created) + else: + try: + user = self.UserModel._default_manager.get_by_natural_key(username) + user = self.configure_user(request, user, created=False) + except self.UserModel.DoesNotExist: + user = None self._temp_claims = None if request and hasattr(request, '_openedx_claims'): delattr(request, '_openedx_claims') - return user + if user and self.user_can_authenticate(user): + return user - return super().authenticate(request, remote_user=remote_user) \ No newline at end of file + return None \ No newline at end of file From 56e57183a0a0350d1c28da264792bb6105c8c1db Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 3 Jul 2026 10:42:01 -0400 Subject: [PATCH 12/14] generalized OpenEdX authentication to OAuth2 + JWT. Authored by Antigravity --- docs/oauth2jwt.md | 52 +++++++++++++++++++++ docs/openedx.md | 59 ----------------------- mkdocs.yml | 2 +- tests/tests.py | 49 ++++++++++---------- userportal/authentication.py | 72 ++++++++++++++++++----------- userportal/settings/43-oauth2jwt.py | 33 +++++++++++++ userportal/settings/43-openedx.py | 35 -------------- userportal/urls.py | 8 ++-- userportal/views.py | 54 ++++++++++++++-------- 9 files changed, 195 insertions(+), 169 deletions(-) create mode 100644 docs/oauth2jwt.md delete mode 100644 docs/openedx.md create mode 100644 userportal/settings/43-oauth2jwt.py delete mode 100644 userportal/settings/43-openedx.py diff --git a/docs/oauth2jwt.md b/docs/oauth2jwt.md new file mode 100644 index 0000000..85b571d --- /dev/null +++ b/docs/oauth2jwt.md @@ -0,0 +1,52 @@ +# Generic OAuth2 + JWT Authentication + +This backend allows users to authenticate against any Identity Provider using OAuth 2.0 and JWT tokens. + +## How it Works + +1. **Authorization Redirect**: When a user goes to `/oauth2/login/`, they are redirected to the Identity Provider authorization endpoint. +2. **Authorization Code Grant**: After successful login at the Identity Provider, the user is redirected back to the callback view (`/oauth2/callback/`) with an authorization code. +3. **JWT Access Token Exchange**: The callback view sends a POST request to the Provider token endpoint to exchange the authorization code for a JWT access token. +4. **Token Decoding**: The custom `staffOAuth2JWTBackend` decodes the JWT payload. + - By default, it decodes with `verify_signature=False`. + - Alternatively, it supports signature verification (like RS256) if `JWT_OAUTH2_VERIFY_SIGNATURE` is set to `True`. +5. **User Mapping**: The backend updates user fields and roles in `configure_user`: + - Synchronizes first name, last name, and email based on claims list. + - Determines `is_staff` using `JWT_OAUTH2_STAFF_ATTRIBUTES`. + - Determines `is_superuser` if the `superuser` claim is `True`. + - Checks access restrictions via `JWT_OAUTH2_REQUIRED_ACCESS_ATTRIBUTES`. + +## Configuration + +To enable generic OAuth2 + JWT authentication: + +1. **Register the OAuth Application in your Identity Provider** and retrieve the **Client ID** and **Client Secret**. Configure the Redirect URI: + - **Redirect URI**: `https:///oauth2/callback/` + +2. **Enable the application and backend** in `userportal/settings/43-oauth2jwt.py` by uncommenting the setup lines: + + ```python + JWT_OAUTH2_AUTH_ENABLED = True + AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOAuth2JWTBackend'] + AUTHENTICATION_BACKENDS + LOGIN_URL = '/oauth2/login/' + ``` + +3. **Configure your Credentials and Endpoints** in `userportal/settings/43-oauth2jwt.py`: + + ```python + JWT_OAUTH2_PROVIDER_URL = 'https://idp.example.com' + JWT_OAUTH2_CLIENT_ID = 'your-client-id' + JWT_OAUTH2_CLIENT_SECRET = 'your-client-secret' + ``` + +4. **Map access and staff roles** based on the claims structure: + + ```python + # Only allow users with specific claims to log in + JWT_OAUTH2_REQUIRED_ACCESS_ATTRIBUTES = [] + + # Automatically set user.is_staff based on claim values + JWT_OAUTH2_STAFF_ATTRIBUTES = [ + ('administrator', True) + ] + ``` diff --git a/docs/openedx.md b/docs/openedx.md deleted file mode 100644 index d977a16..0000000 --- a/docs/openedx.md +++ /dev/null @@ -1,59 +0,0 @@ -# OpenEdX OAuth + JWT Authentication - -This backend allows users to authenticate against an OpenEdX LMS instance using OAuth 2.0 and JWT tokens. It is inspired by the Single Sign-On (SSO) implementation for Apache Superset in OpenEdX Aspects (`openedx_sso_security_manager.py`). - -## How it Works - -1. **Authorization Redirect**: When a user goes to `/openedx/login/`, they are redirected to the OpenEdX LMS authorization endpoint (`/oauth2/authorize/`). -2. **Authorization Code Grant**: After successful login at the LMS, the user is redirected back to the callback view (`/openedx/callback/`) with an authorization code. -3. **JWT Access Token Exchange**: The callback view sends a POST request to the LMS token endpoint (`/oauth2/access_token/`) specifying `token_type=jwt` as a parameter. OpenEdX LMS then issues a JSON Web Token (JWT) as the access token containing the user's claims. -4. **Token Decoding**: The custom `staffOpenEdxBackend` decodes the JWT payload. - - By default, it follows the `openedx_sso_security_manager` pattern of decoding with `verify_signature=False` (since the token was exchanged directly and securely server-to-server with the provider over HTTPS, validation of the signature is optional). - - Alternatively, it supports standard RS256/HS256 signature verification if `OPENEDX_VERIFY_SIGNATURE` is set to `True`. -5. **User Mapping (Similar to SAML2)**: Similar to the SAML2 implementation, the backend updates user fields and roles in `_update_user`: - - Synchronizes first name, last name, and email. - - Determines `is_staff` using `OPENEDX_STAFF_ATTRIBUTES` (e.g. checking if the `administrator` claim is `True`). - - Determines `is_superuser` if the `superuser` claim is `True`. - - Checks access restrictions via `OPENEDX_REQUIRED_ACCESS_ATTRIBUTES`. - -## Configuration - -To enable OpenEdX authentication: - -1. **Register the OAuth Application in OpenEdX**: - - Log into the LMS Django Admin panel. - - Go to **Django OAuth Toolkit** > **Applications**. - - Create a new application: - - **Client Type**: `Confidential` - - **Authorization Grant Type**: `Authorization Code` - - **Redirect URIs**: `https:///openedx/callback/` - - Retrieve the **Client ID** and **Client Secret**. - -2. **Enable the application and backend** in `userportal/settings/43-openedx.py` by uncommenting the setup lines: - - ```python - OPENEDX_AUTH_ENABLED = True - AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOpenEdxBackend'] + AUTHENTICATION_BACKENDS - LOGIN_URL = '/openedx/login/' - ``` - -3. **Configure your LMS Credentials and Endpoints** in `userportal/settings/43-openedx.py`: - - ```python - OPENEDX_LMS_URL = 'https://lms.example.com' - OPENEDX_CLIENT_ID = 'your-client-id' - OPENEDX_CLIENT_SECRET = 'your-client-secret' - ``` - -4. **Map access and staff roles** based on the returned claims: - - ```python - # Only allow users with specific claims to log in - OPENEDX_REQUIRED_ACCESS_ATTRIBUTES = [] - - # Automatically set user.is_staff based on OpenEdX claim values - # By default, the OpenEdX profile scope includes 'administrator': True for staff - OPENEDX_STAFF_ATTRIBUTES = [ - ('administrator', True) - ] - ``` diff --git a/mkdocs.yml b/mkdocs.yml index 57a6837..8c00cfd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,7 +16,7 @@ nav: - 'Quotas GPFS': quotasgpfs.md - 'CF Access': cfaccess.md - 'OIDC / OAuth': oidc.md - - 'OpenEdX OAuth + JWT': openedx.md + - 'OAuth2 + JWT': oauth2jwt.md theme: diff --git a/tests/tests.py b/tests/tests.py index 05832db..98deffe 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -30,31 +30,32 @@ def assertJSONKeys(self, response, keys): from django.urls import reverse from unittest.mock import patch, MagicMock -class OpenEdxAuthTestCase(TestCase): +class OAuth2JWTAuthTestCase(TestCase): databases = '__all__' @override_settings( - OPENEDX_AUTH_ENABLED=True, - OPENEDX_LMS_URL='https://lms.example.com', - OPENEDX_CLIENT_ID='test-client-id', - OPENEDX_CLIENT_SECRET='test-client-secret', - OPENEDX_VERIFY_SIGNATURE=False, + JWT_OAUTH2_AUTH_ENABLED=True, + JWT_OAUTH2_PROVIDER_URL='https://idp.example.com', + JWT_OAUTH2_CLIENT_ID='test-client-id', + JWT_OAUTH2_CLIENT_SECRET='test-client-secret', + JWT_OAUTH2_VERIFY_SIGNATURE=False, ) def test_login_redirect(self): client = Client() - response = client.get(reverse('openedx_login')) + response = client.get(reverse('oauth2_jwt_login')) self.assertEqual(response.status_code, 302) - self.assertTrue(response['Location'].startswith('https://lms.example.com/oauth2/authorize/')) + self.assertTrue(response['Location'].startswith('https://idp.example.com/oauth2/authorize/')) self.assertIn('client_id=test-client-id', response['Location']) self.assertIn('response_type=code', response['Location']) @override_settings( - OPENEDX_AUTH_ENABLED=True, - OPENEDX_LMS_URL='https://lms.example.com', - OPENEDX_CLIENT_ID='test-client-id', - OPENEDX_CLIENT_SECRET='test-client-secret', - OPENEDX_VERIFY_SIGNATURE=False, - OPENEDX_STAFF_ATTRIBUTES=[('administrator', True)], + JWT_OAUTH2_AUTH_ENABLED=True, + JWT_OAUTH2_PROVIDER_URL='https://idp.example.com', + JWT_OAUTH2_CLIENT_ID='test-client-id', + JWT_OAUTH2_CLIENT_SECRET='test-client-secret', + JWT_OAUTH2_VERIFY_SIGNATURE=False, + JWT_OAUTH2_STAFF_ATTRIBUTES=[('administrator', True)], + AUTHENTICATION_BACKENDS=['userportal.authentication.staffOAuth2JWTBackend'] + settings.AUTHENTICATION_BACKENDS, ) @patch('requests.post') @patch('jwt.decode') @@ -67,10 +68,10 @@ def test_callback_success(self, mock_jwt_decode, mock_post): # Mock jwt.decode for payload extraction mock_jwt_decode.return_value = { - 'username': 'openedxuser', - 'email': 'openedxuser@example.com', - 'given_name': 'Open', - 'family_name': 'EdX', + 'username': 'oauth2user', + 'email': 'oauth2user@example.com', + 'given_name': 'OAuth2', + 'family_name': 'User', 'administrator': True, 'superuser': False, } @@ -78,10 +79,10 @@ def test_callback_success(self, mock_jwt_decode, mock_post): # Set session state client = Client() session = client.session - session['openedx_oauth_state'] = 'teststate' + session['oauth2_jwt_state'] = 'teststate' session.save() - response = client.get(reverse('openedx_callback'), {'state': 'teststate', 'code': 'testcode'}) + response = client.get(reverse('oauth2_jwt_callback'), {'state': 'teststate', 'code': 'testcode'}) # Verify redirect to home self.assertEqual(response.status_code, 302) @@ -89,10 +90,10 @@ def test_callback_success(self, mock_jwt_decode, mock_post): # Verify user was created User = get_user_model() - user = User.objects.get(username='openedxuser') - self.assertEqual(user.email, 'openedxuser@example.com') - self.assertEqual(user.first_name, 'Open') - self.assertEqual(user.last_name, 'EdX') + user = User.objects.get(username='oauth2user') + self.assertEqual(user.email, 'oauth2user@example.com') + self.assertEqual(user.first_name, 'OAuth2') + self.assertEqual(user.last_name, 'User') self.assertTrue(user.is_staff) self.assertFalse(user.is_superuser) diff --git a/userportal/authentication.py b/userportal/authentication.py index 5ab87b1..4903b3d 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -1,5 +1,9 @@ from djangosaml2.backends import Saml2Backend from django.contrib.auth.backends import ModelBackend, RemoteUserBackend +from django.conf import settings + + + class staffSaml2Backend(Saml2Backend): @@ -105,8 +109,8 @@ def update_user(self, user, claims): pass -class staffOpenEdxBackend(ModelBackend): - """Authentication backend for OpenEdX OAuth2 + JWT tokens, using ModelBackend.""" +class staffOAuth2JWTBackend(ModelBackend): + """Authentication backend for generic OAuth2 + JWT tokens.""" @property def UserModel(self): @@ -115,27 +119,24 @@ def UserModel(self): @property def create_unknown_user(self): - return getattr(settings, 'OPENEDX_CREATE_UNKNOWN_USER', True) + return getattr(settings, 'JWT_OAUTH2_CREATE_UNKNOWN_USER', True) def verify_token(self, token): import jwt try: - verify_signature = getattr(settings, 'OPENEDX_VERIFY_SIGNATURE', False) + verify_signature = getattr(settings, 'JWT_OAUTH2_VERIFY_SIGNATURE', False) if verify_signature: from jwt import PyJWKClient - jwks_endpoint = getattr(settings, 'OPENEDX_JWKS_ENDPOINT', None) - if not jwks_endpoint and hasattr(settings, 'OPENEDX_LMS_URL'): - jwks_endpoint = f"{settings.OPENEDX_LMS_URL.rstrip('/')}/oauth2/jwks/" - - algorithms = [getattr(settings, 'OPENEDX_SIGN_ALGO', 'RS256')] - audience = getattr(settings, 'OPENEDX_CLIENT_ID', None) + jwks_endpoint = getattr(settings, 'JWT_OAUTH2_JWKS_ENDPOINT', None) + algorithms = getattr(settings, 'JWT_OAUTH2_SIGN_ALGOS', [getattr(settings, 'JWT_OAUTH2_SIGN_ALGO', 'RS256')]) + audience = getattr(settings, 'JWT_OAUTH2_CLIENT_ID', None) if jwks_endpoint and 'RS256' in algorithms: jwk_client = PyJWKClient(jwks_endpoint) signing_key = jwk_client.get_signing_key_from_jwt(token) key = signing_key.key else: - key = getattr(settings, 'OPENEDX_SECRET_KEY', getattr(settings, 'OPENEDX_CLIENT_SECRET', None)) + key = getattr(settings, 'JWT_OAUTH2_SECRET_KEY', getattr(settings, 'JWT_OAUTH2_CLIENT_SECRET', None)) if not key: raise ValueError("No signature verification key or JWKS endpoint configured.") @@ -148,7 +149,7 @@ def verify_token(self, token): options={"verify_aud": bool(audience)} ) else: - # Decode user info without verification, inspired by openedx_sso_security_manager in tutor-contrib-aspects + # Decode user info without verification payload = jwt.decode( token, options={"verify_signature": False} @@ -157,11 +158,11 @@ def verify_token(self, token): except Exception as e: import logging logger = logging.getLogger(__name__) - logger.error(f"Failed to decode/verify OpenEdX JWT token: {e}") + logger.error(f"Failed to decode/verify JWT token: {e}") return None def verify_claims(self, claims): - req_attrs = getattr(settings, 'OPENEDX_REQUIRED_ACCESS_ATTRIBUTES', []) + req_attrs = getattr(settings, 'JWT_OAUTH2_REQUIRED_ACCESS_ATTRIBUTES', []) for attribute, value in req_attrs: claim_val = claims.get(attribute) if claim_val is None: @@ -181,17 +182,33 @@ def clean_username(self, username): def configure_user(self, request, user, created=True): """Configure user based on the decoded claims stored in request or backend.""" - claims = getattr(request, '_openedx_claims', getattr(self, '_temp_claims', None)) + claims = getattr(request, '_oauth2_jwt_claims', getattr(self, '_temp_claims', None)) if not claims: return user - user.first_name = claims.get('given_name', claims.get('first_name', claims.get('name', ''))) - user.last_name = claims.get('family_name', claims.get('last_name', '')) - user.email = claims.get('email', '') + # Set first_name + first_name_claims = getattr(settings, 'JWT_OAUTH2_FIRST_NAME_CLAIMS', ['given_name', 'first_name', 'name']) + for claim in first_name_claims: + val = claims.get(claim) + if val: + user.first_name = val + break + + # Set last_name + last_name_claims = getattr(settings, 'JWT_OAUTH2_LAST_NAME_CLAIMS', ['family_name', 'last_name']) + for claim in last_name_claims: + val = claims.get(claim) + if val: + user.last_name = val + break + + # Set email + email_claim = getattr(settings, 'JWT_OAUTH2_EMAIL_CLAIM', 'email') + user.email = claims.get(email_claim, '') # Set staff status user.is_staff = False - staff_attrs = getattr(settings, 'OPENEDX_STAFF_ATTRIBUTES', [('administrator', True)]) + staff_attrs = getattr(settings, 'JWT_OAUTH2_STAFF_ATTRIBUTES', [('administrator', True)]) for attribute, value in staff_attrs: claim_val = claims.get(attribute) if claim_val is not None: @@ -213,7 +230,7 @@ def configure_user(self, request, user, created=True): def authenticate(self, request, remote_user=None, token=None, **kwargs): """ - Authenticate with OpenEdX JWT token. + Authenticate with JWT token. """ if token: claims = self.verify_token(token) @@ -222,9 +239,12 @@ def authenticate(self, request, remote_user=None, token=None, **kwargs): if not self.verify_claims(claims): return None - username = claims.get('preferred_username') or claims.get('username') - if not username: - username = claims.get('sub') + username = None + username_claims = getattr(settings, 'JWT_OAUTH2_USERNAME_CLAIMS', ['preferred_username', 'username', 'sub']) + for claim in username_claims: + username = claims.get(claim) + if username: + break if not username: return None @@ -232,7 +252,7 @@ def authenticate(self, request, remote_user=None, token=None, **kwargs): # Store claims temporarily where configure_user can access them self._temp_claims = claims if request: - request._openedx_claims = claims + request._oauth2_jwt_claims = claims # Find or create user username = self.clean_username(username) @@ -249,8 +269,8 @@ def authenticate(self, request, remote_user=None, token=None, **kwargs): user = None self._temp_claims = None - if request and hasattr(request, '_openedx_claims'): - delattr(request, '_openedx_claims') + if request and hasattr(request, '_oauth2_jwt_claims'): + delattr(request, '_oauth2_jwt_claims') if user and self.user_can_authenticate(user): return user diff --git a/userportal/settings/43-oauth2jwt.py b/userportal/settings/43-oauth2jwt.py new file mode 100644 index 0000000..b885d30 --- /dev/null +++ b/userportal/settings/43-oauth2jwt.py @@ -0,0 +1,33 @@ +# Generic OAuth2 + JWT Settings +# For documentation, see docs/oauth2jwt.md + +# To enable generic OAuth2 + JWT authentication, uncomment these lines: +# JWT_OAUTH2_AUTH_ENABLED = True +# AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOAuth2JWTBackend'] + AUTHENTICATION_BACKENDS +# LOGIN_URL = '/oauth2/login/' +# LOGIN_REDIRECT_URL = '/' +# LOGOUT_REDIRECT_URL = '/' + +# Provider configuration: +JWT_OAUTH2_PROVIDER_URL = 'https://idp.example.com' +JWT_OAUTH2_CLIENT_ID = 'your-client-id' +JWT_OAUTH2_CLIENT_SECRET = 'your-client-secret' + +# JWT signature verification options +# By default, verify_signature can be False when using server-to-server token exchange +JWT_OAUTH2_VERIFY_SIGNATURE = False +JWT_OAUTH2_SIGN_ALGO = 'RS256' + +# Scope to request from the Identity Provider +JWT_OAUTH2_SCOPE = 'read write email profile' + +# Create Django user if they don't exist +JWT_OAUTH2_CREATE_UNKNOWN_USER = True + +# List of tuples of (claim_name, expected_value) for access requirements (must all match) +JWT_OAUTH2_REQUIRED_ACCESS_ATTRIBUTES = [] + +# List of tuples of (claim_name, expected_value). If ANY matches, user.is_staff will be True. +JWT_OAUTH2_STAFF_ATTRIBUTES = [ + ('administrator', True) +] diff --git a/userportal/settings/43-openedx.py b/userportal/settings/43-openedx.py deleted file mode 100644 index 7ea2578..0000000 --- a/userportal/settings/43-openedx.py +++ /dev/null @@ -1,35 +0,0 @@ -# OpenEdX OAuth + JWT Settings -# For documentation, see docs/openedx.md - -# To enable OpenEdX authentication, uncomment these lines: -# OPENEDX_AUTH_ENABLED = True -# AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOpenEdxBackend'] + AUTHENTICATION_BACKENDS -# LOGIN_URL = '/openedx/login/' -# LOGIN_REDIRECT_URL = '/' -# LOGOUT_REDIRECT_URL = '/' - -# OpenEdX LMS Provider configurations: -OPENEDX_LMS_URL = 'https://lms.example.com' -OPENEDX_CLIENT_ID = 'your-client-id' -OPENEDX_CLIENT_SECRET = 'your-client-secret' - -# JWT signature verification options -# By default, verify_signature can be False when using server-to-server token exchange, -# similar to openedx_sso_security_manager in tutor-contrib-aspects. -OPENEDX_VERIFY_SIGNATURE = False -OPENEDX_SIGN_ALGO = 'RS256' - -# Scope to request from OpenEdX -OPENEDX_SCOPE = 'read write email profile' - -# Create Django user if they don't exist (similar to SAML_CREATE_UNKNOWN_USER) -OPENEDX_CREATE_UNKNOWN_USER = True - -# List of tuples of (claim_name, expected_value) for access requirements (must all match) -OPENEDX_REQUIRED_ACCESS_ATTRIBUTES = [] - -# List of tuples of (claim_name, expected_value). If ANY matches, user.is_staff will be True. -# Under OpenEdX profile scope, 'administrator' is set to True for staff users. -OPENEDX_STAFF_ATTRIBUTES = [ - ('administrator', True) -] diff --git a/userportal/urls.py b/userportal/urls.py index 135c965..b2568dc 100644 --- a/userportal/urls.py +++ b/userportal/urls.py @@ -70,11 +70,11 @@ path('oidc/', include('mozilla_django_oidc.urls')), ] -if getattr(settings, 'OPENEDX_AUTH_ENABLED', False): - from userportal.views import OpenEdxLoginView, OpenEdxCallbackView +if getattr(settings, 'JWT_OAUTH2_AUTH_ENABLED', False): + from userportal.views import OAuth2JWTLoginView, OAuth2JWTCallbackView urlpatterns += [ - path('openedx/login/', OpenEdxLoginView.as_view(), name='openedx_login'), - path('openedx/callback/', OpenEdxCallbackView.as_view(), name='openedx_callback'), + path('oauth2/login/', OAuth2JWTLoginView.as_view(), name='oauth2_jwt_login'), + path('oauth2/callback/', OAuth2JWTCallbackView.as_view(), name='oauth2_jwt_callback'), ] diff --git a/userportal/views.py b/userportal/views.py index 5ea2c71..dbbb7dd 100644 --- a/userportal/views.py +++ b/userportal/views.py @@ -10,25 +10,31 @@ logger = logging.getLogger(__name__) -class OpenEdxLoginView(View): - """View to redirect users to OpenEdX LMS for authorization.""" +class OAuth2JWTLoginView(View): + """View to redirect users to OAuth2 Identity Provider for authorization.""" def get(self, request, *args, **kwargs): # Generate a random state to prevent CSRF state = secrets.token_urlsafe(32) - request.session['openedx_oauth_state'] = state + request.session['oauth2_jwt_state'] = state - lms_url = settings.OPENEDX_LMS_URL.rstrip('/') - auth_url = f"{lms_url}/oauth2/authorize/" + auth_url = getattr(settings, 'JWT_OAUTH2_AUTHORIZATION_URL', None) + if not auth_url: + provider_url = getattr(settings, 'JWT_OAUTH2_PROVIDER_URL', None) + if provider_url: + auth_url = f"{provider_url.rstrip('/')}/oauth2/authorize/" + + if not auth_url: + return HttpResponseBadRequest("Authorization URL is not configured.") # Build authorization redirect URL - redirect_uri = request.build_absolute_uri(reverse('openedx_callback')) + redirect_uri = request.build_absolute_uri(reverse('oauth2_jwt_callback')) params = { 'response_type': 'code', - 'client_id': settings.OPENEDX_CLIENT_ID, + 'client_id': getattr(settings, 'JWT_OAUTH2_CLIENT_ID', None), 'redirect_uri': redirect_uri, - 'scope': getattr(settings, 'OPENEDX_SCOPE', 'read write email profile'), + 'scope': getattr(settings, 'JWT_OAUTH2_SCOPE', 'read write email profile'), 'state': state, } @@ -36,13 +42,13 @@ def get(self, request, *args, **kwargs): return HttpResponseRedirect(url_with_params) -class OpenEdxCallbackView(View): - """View to handle OpenEdX OAuth callback and authenticate the user.""" +class OAuth2JWTCallbackView(View): + """View to handle OAuth2 callback and authenticate the user using JWT token.""" def get(self, request, *args, **kwargs): # Validate state state = request.GET.get('state') - session_state = request.session.pop('openedx_oauth_state', None) + session_state = request.session.pop('oauth2_jwt_state', None) if not state or state != session_state: return HttpResponseBadRequest("Invalid state parameter.") @@ -50,32 +56,40 @@ def get(self, request, *args, **kwargs): if not code: return HttpResponseBadRequest("Missing authorization code.") - lms_url = settings.OPENEDX_LMS_URL.rstrip('/') - token_url = f"{lms_url}/oauth2/access_token/" + token_url = getattr(settings, 'JWT_OAUTH2_TOKEN_URL', None) + if not token_url: + provider_url = getattr(settings, 'JWT_OAUTH2_PROVIDER_URL', None) + if provider_url: + token_url = f"{provider_url.rstrip('/')}/oauth2/access_token/" + + if not token_url: + return HttpResponseBadRequest("Token URL is not configured.") - redirect_uri = request.build_absolute_uri(reverse('openedx_callback')) + redirect_uri = request.build_absolute_uri(reverse('oauth2_jwt_callback')) - # Exchange authorization code for JWT access token + # Exchange authorization code for access token data = { 'grant_type': 'authorization_code', - 'client_id': settings.OPENEDX_CLIENT_ID, - 'client_secret': settings.OPENEDX_CLIENT_SECRET, + 'client_id': getattr(settings, 'JWT_OAUTH2_CLIENT_ID', None), + 'client_secret': getattr(settings, 'JWT_OAUTH2_CLIENT_SECRET', None), 'code': code, 'redirect_uri': redirect_uri, - 'token_type': 'jwt', # OpenEdX specific parameter to request a JWT } + extra_params = getattr(settings, 'JWT_OAUTH2_EXTRA_TOKEN_PARAMS', {}) + data.update(extra_params) + try: response = requests.post(token_url, data=data, timeout=10) response.raise_for_status() res_data = response.json() except Exception as e: - logger.error(f"Failed to exchange code for OpenEdX token: {e}") + logger.error(f"Failed to exchange code for OAuth2 token: {e}") return HttpResponseBadRequest("Token exchange failed.") access_token = res_data.get('access_token') if not access_token: - return HttpResponseBadRequest("No access token returned from OpenEdX.") + return HttpResponseBadRequest("No access token returned from Identity Provider.") # Authenticate user with the token using the custom backend user = authenticate(request, token=access_token) From 5cdd046c7fd5c6ccdf5f80d6b0e9cd3183db0a01 Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 3 Jul 2026 10:42:39 -0400 Subject: [PATCH 13/14] add documentation for JWT_OAUTH2_AUTHORIZATION_URL JWT_OAUTH2_TOKEN_URL and JWT_OAUTH2_EXTRA_TOKEN_PARAMS, which antigravity missed --- docs/oauth2jwt.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/oauth2jwt.md b/docs/oauth2jwt.md index 85b571d..30bcff0 100644 --- a/docs/oauth2jwt.md +++ b/docs/oauth2jwt.md @@ -7,7 +7,7 @@ This backend allows users to authenticate against any Identity Provider using OA 1. **Authorization Redirect**: When a user goes to `/oauth2/login/`, they are redirected to the Identity Provider authorization endpoint. 2. **Authorization Code Grant**: After successful login at the Identity Provider, the user is redirected back to the callback view (`/oauth2/callback/`) with an authorization code. 3. **JWT Access Token Exchange**: The callback view sends a POST request to the Provider token endpoint to exchange the authorization code for a JWT access token. -4. **Token Decoding**: The custom `staffOAuth2JWTBackend` decodes the JWT payload. +4. **Token Decoding**: The custom `staffOAuth2JWTBackend` decodes the JWT payload. - By default, it decodes with `verify_signature=False`. - Alternatively, it supports signature verification (like RS256) if `JWT_OAUTH2_VERIFY_SIGNATURE` is set to `True`. 5. **User Mapping**: The backend updates user fields and roles in `configure_user`: @@ -39,6 +39,13 @@ To enable generic OAuth2 + JWT authentication: JWT_OAUTH2_CLIENT_SECRET = 'your-client-secret' ``` +You can also specify `JWT_OAUTH2_AUTHORIZATION_URL` and `JWT_OAUTH2_TOKEN_URL` explicitly, which by default have the value of `JWT_OAUTH2_PROVIDER_URL/oauth2/authorize/` and `JWT_OAUTH2_PROVIDER_URL/oauth2/access_token/` respectively. + +If needed, you can specify extra token parameters with `JWT_OAUTH2_EXTRA_TOKEN_PARAMS`. This allows to pass additional information to the OAUTH2 endpoint. For OpenEdX, define + ```python + JWT_OAUTH2_EXTRA_TOKEN_PARAMS = {'token_type': 'jwt'} + ``` + 4. **Map access and staff roles** based on the claims structure: ```python From e1c65ea78a46f23bfd12b55c169804db86b5f747 Mon Sep 17 00:00:00 2001 From: Maxime Boissonneault Date: Fri, 3 Jul 2026 12:35:00 -0400 Subject: [PATCH 14/14] better handle claim comparisons, especially for boolean values expressed as strings --- userportal/authentication.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/userportal/authentication.py b/userportal/authentication.py index 8fdadb9..4dc1e02 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -107,7 +107,7 @@ def _update_user_attributes(self, user, claims): user.is_staff = True break else: - if value == claim_val: + if str(value).lower() == str(claim_val).lower(): user.is_staff = True break @@ -123,7 +123,7 @@ def _update_user_attributes(self, user, claims): user.is_active = False break else: - if value != claim_val: + if str(value).lower() != str(claim_val).lower(): user.is_active = False break @@ -205,7 +205,7 @@ def verify_claims(self, claims): if value not in claim_val: return False else: - if value != claim_val: + if str(value).lower() != str(claim_val).lower(): return False return True @@ -251,7 +251,7 @@ def configure_user(self, request, user, created=True): user.is_staff = True break else: - if value == claim_val: + if str(value).lower() == str(claim_val).lower(): user.is_staff = True break