Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ea20c53
make staff and access attributes configurable for saml2
mboisson Apr 9, 2026
fe6cddc
moved djangosaml2 to a try except to define the class only if djangos…
mboisson Apr 10, 2026
bdbf8d8
make is_staff configurable based on LDAP attributes
mboisson Apr 10, 2026
1ac9804
Merge branch 'attributes_based_saml' into staff_attributes
mboisson Apr 10, 2026
54e0cea
also add required_access_attributes to LDAP backend
mboisson Apr 17, 2026
ac2a9ac
no need to continue the loop if one attribute is missing
mboisson Apr 17, 2026
543ca79
first generation of code by antigravity
mboisson Jun 16, 2026
291809e
Merge branch 'guilbaults:main' into oauth
mboisson Jun 16, 2026
2291e0b
added middleware to handle the case of multiple proxies, needed so th…
mboisson Jun 19, 2026
68137ac
fix antigravity's mistakes
mboisson Jun 19, 2026
4569f1c
need to override filter_users_by_claims to use username, and verify c…
mboisson Jun 19, 2026
3efa99c
antigravity's pitch at implementing Oauth2 + JWT with OpenEdX
mboisson Jun 23, 2026
df6f685
switch from RemoteUserBackend to ModelBackend
mboisson Jul 3, 2026
56e5718
generalized OpenEdX authentication to OAuth2 + JWT. Authored by Antig…
mboisson Jul 3, 2026
5cdd046
add documentation for JWT_OAUTH2_AUTHORIZATION_URL JWT_OAUTH2_TOKEN_U…
mboisson Jul 3, 2026
dd55137
Merge branch 'oauth' into generalized_authentication
mboisson Jul 3, 2026
f2846e6
Merge branch 'openedx' into generalized_authentication
mboisson Jul 3, 2026
e1c65ea
better handle claim comparisons, especially for boolean values expres…
mboisson Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions docs/oauth2jwt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 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://<your-portal-domain>/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'
```

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
# 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)
]
```
56 changes: 56 additions & 0 deletions docs/oidc.md
Original file line number Diff line number Diff line change
@@ -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/).
3 changes: 3 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ nav:
- 'Quotas': quotas.md
- 'Quotas GPFS': quotasgpfs.md
- 'CF Access': cfaccess.md
- 'OIDC / OAuth': oidc.md
- 'OAuth2 + JWT': oauth2jwt.md


theme:
name: material
Expand Down
21 changes: 21 additions & 0 deletions multipleproxy/middleware.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

73 changes: 71 additions & 2 deletions tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from django.test import TestCase
from django.test import Client, override_settings, TestCase
from django.contrib.auth import get_user_model
from django.conf import settings
from django.test import Client
from django.urls import reverse
from unittest.mock import patch, MagicMock


class CustomTestCase(TestCase):
Expand All @@ -24,3 +25,71 @@ def setUp(self):

def assertJSONKeys(self, response, keys):
self.assertEqual(set(response.json().keys()), set(keys))


class OAuth2JWTAuthTestCase(TestCase):
databases = '__all__'

@override_settings(
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('oauth2_jwt_login'))
self.assertEqual(response.status_code, 302)
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(
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')
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': 'oauth2user',
'email': 'oauth2user@example.com',
'given_name': 'OAuth2',
'family_name': 'User',
'administrator': True,
'superuser': False,
}

# Set session state
client = Client()
session = client.session
session['oauth2_jwt_state'] = 'teststate'
session.save()

response = client.get(reverse('oauth2_jwt_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='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)
Loading
Loading