Skip to content

Commit f00f980

Browse files
committed
✨(oidc) add the authentication backends
This provides: - the OIDC authentication backend we use on our project to allow connection via OIDC - the OIDC authentication backend for URLs to expose via Resource Server
1 parent c0d76bb commit f00f980

33 files changed

+2569
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ and this project adheres to
1111
### Added
1212

1313
- ✨(tools) extract domain from email address #2
14+
- ✨(oidc) add the authentication backends #2
1415

1516
[unreleased]: https://github.com/suitenumerique/django-lasuite/commits/main/

pyproject.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ classifiers = [
2424
]
2525
dependencies = [
2626
"django>=5.0",
27+
"djangorestframework>=3.15.2",
28+
"mozilla-django-oidc>=4.0.1",
29+
"joserfc>=1.0.4",
30+
"requests>=2.32.3",
31+
"PyJWT>=2.10.1",
2732
]
2833

2934
[project.urls]
@@ -36,7 +41,10 @@ build = [
3641
"wheel",
3742
]
3843
dev = [
44+
"factory_boy",
3945
"pytest",
46+
"pytest-django",
47+
"responses",
4048
"ruff",
4149
]
4250

@@ -110,6 +118,8 @@ lint.per-file-ignores = {"**/tests/*"= [
110118
"S",
111119
# flake8-self
112120
"SLF",
121+
# magic-value-comparison
122+
"PLR2004",
113123
]}
114124

115125

src/lasuite/oidc/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""OIDC authentication module."""

src/lasuite/oidc/backends.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Authentication Backends for the People core app."""
2+
3+
import logging
4+
5+
import requests
6+
from django.conf import settings
7+
from django.core.exceptions import SuspiciousOperation
8+
from django.utils.translation import gettext_lazy as _
9+
from mozilla_django_oidc.auth import (
10+
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
11+
)
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
OIDC_USER_SUB_FIELD = getattr(
17+
settings,
18+
"OIDC_USER_SUB_FIELD",
19+
"sub",
20+
) # Default to 'sub' if not set in settings
21+
22+
23+
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
24+
"""
25+
Custom OpenID Connect (OIDC) Authentication Backend.
26+
27+
This class overrides the default OIDC Authentication Backend to accommodate differences
28+
in the User model, and handles signed and/or encrypted UserInfo response.
29+
"""
30+
31+
def get_extra_claims(self, user_info):
32+
"""
33+
Return extra claims from user_info.
34+
35+
Args:
36+
user_info (dict): The user information dictionary.
37+
38+
Returns:
39+
dict: A dictionary of extra claims.
40+
41+
"""
42+
return {
43+
"name": self.compute_full_name(user_info),
44+
}
45+
46+
def post_get_or_create_user(self, user, claims):
47+
"""
48+
Post-processing after user creation or retrieval.
49+
50+
Args:
51+
user (User): The user instance.
52+
claims (dict): The claims dictionary.
53+
54+
Returns:
55+
- None
56+
57+
"""
58+
59+
def get_userinfo(self, access_token, id_token, payload):
60+
"""
61+
Return user details dictionary.
62+
63+
Args:
64+
access_token (str): The access token.
65+
id_token (str): The id token (unused).
66+
payload (dict): The token payload (unused).
67+
68+
Note: The id_token and payload parameters are unused in this implementation,
69+
but were kept to preserve base method signature.
70+
71+
Note: It handles signed and/or encrypted UserInfo Response. It is required by
72+
Agent Connect, which follows the OIDC standard. It forces us to override the
73+
base method, which deal with 'application/json' response.
74+
75+
Returns:
76+
dict: User details dictionary obtained from the OpenID Connect user endpoint.
77+
78+
"""
79+
user_response = requests.get(
80+
self.OIDC_OP_USER_ENDPOINT,
81+
headers={"Authorization": f"Bearer {access_token}"},
82+
verify=self.get_settings("OIDC_VERIFY_SSL", True),
83+
timeout=self.get_settings("OIDC_TIMEOUT", None),
84+
proxies=self.get_settings("OIDC_PROXY", None),
85+
)
86+
user_response.raise_for_status()
87+
return self.verify_token(user_response.text)
88+
89+
def get_or_create_user(self, access_token, id_token, payload):
90+
"""
91+
Return a User based on userinfo. Create a new user if no match is found.
92+
93+
Args:
94+
access_token (str): The access token.
95+
id_token (str): The ID token.
96+
payload (dict): The user payload.
97+
98+
Returns:
99+
User: An existing or newly created User instance.
100+
101+
Raises:
102+
Exception: Raised when user creation is not allowed and no existing user is found.
103+
104+
"""
105+
user_info = self.get_userinfo(access_token, id_token, payload)
106+
107+
sub = user_info.get("sub")
108+
if not sub:
109+
raise SuspiciousOperation(_("User info contained no recognizable user identification"))
110+
111+
# Get user's full name from OIDC fields defined in settings
112+
email = user_info.get("email")
113+
114+
claims = {
115+
OIDC_USER_SUB_FIELD: sub,
116+
"email": email,
117+
}
118+
claims.update(**self.get_extra_claims(user_info))
119+
120+
# if sub is absent, try matching on email
121+
user = self.get_existing_user(sub, email)
122+
123+
if user:
124+
if not user.is_active:
125+
raise SuspiciousOperation(_("User account is disabled"))
126+
self.update_user_if_needed(user, claims)
127+
128+
elif self.get_settings("OIDC_CREATE_USER", True):
129+
user = self.create_user(claims)
130+
131+
self.post_get_or_create_user(user, claims)
132+
return user
133+
134+
def create_user(self, claims):
135+
"""Return a newly created User instance."""
136+
sub = claims.get("sub")
137+
if sub is None:
138+
raise SuspiciousOperation(_("Claims contained no recognizable user identification"))
139+
140+
logger.info("Creating user %s", sub)
141+
142+
user = self.UserModel(**claims)
143+
user.set_unusable_password()
144+
user.save()
145+
146+
return user
147+
148+
def compute_full_name(self, user_info):
149+
"""Compute user's full name based on OIDC fields in settings."""
150+
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
151+
full_name = " ".join(user_info[field] for field in name_fields if user_info.get(field))
152+
return full_name or None
153+
154+
def get_existing_user(self, sub, email):
155+
"""Fetch existing user by sub or email."""
156+
try:
157+
return self.UserModel.objects.get(sub=sub)
158+
except self.UserModel.DoesNotExist:
159+
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
160+
try:
161+
return self.UserModel.objects.get(email=email)
162+
except self.UserModel.DoesNotExist:
163+
pass
164+
return None
165+
166+
def update_user_if_needed(self, user, claims):
167+
"""Update user claims if they have changed."""
168+
updated_claims = {}
169+
for key in claims:
170+
if not hasattr(user, key):
171+
continue
172+
173+
claim_value = claims.get(key)
174+
if claim_value and claim_value != getattr(user, key):
175+
updated_claims[key] = claim_value
176+
177+
if updated_claims:
178+
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)

src/lasuite/oidc/urls.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Authentication URLs for the People core app."""
2+
3+
from django.urls import path
4+
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
5+
6+
from .views import OIDCLogoutCallbackView, OIDCLogoutView
7+
8+
urlpatterns = [
9+
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
10+
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
11+
path(
12+
"logout-callback/",
13+
OIDCLogoutCallbackView.as_view(),
14+
name="oidc_logout_callback",
15+
),
16+
*mozilla_oidc_urls,
17+
]

src/lasuite/oidc/views.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Authentication Views for the People core app."""
2+
3+
from urllib.parse import urlencode
4+
5+
from django.contrib import auth
6+
from django.core.exceptions import SuspiciousOperation
7+
from django.http import HttpResponseRedirect
8+
from django.urls import reverse
9+
from django.utils import crypto
10+
from mozilla_django_oidc.utils import (
11+
absolutify,
12+
)
13+
from mozilla_django_oidc.views import (
14+
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
15+
)
16+
17+
18+
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
19+
"""
20+
Custom logout view for handling OpenID Connect (OIDC) logout flow.
21+
22+
Adds support for handling logout callbacks from the identity provider (OP)
23+
by initiating the logout flow if the user has an active session.
24+
25+
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
26+
This parameter is crucial for maintaining the integrity of the logout flow between this call
27+
and the subsequent callback.
28+
"""
29+
30+
@staticmethod
31+
def persist_state(request, state):
32+
"""
33+
Persist the given 'state' parameter in the session's 'oidc_states' dictionary.
34+
35+
This method is used to store the OIDC state parameter in the session, according to the
36+
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
37+
utility function.
38+
"""
39+
if "oidc_states" not in request.session or not isinstance(request.session["oidc_states"], dict):
40+
request.session["oidc_states"] = {}
41+
42+
request.session["oidc_states"][state] = {}
43+
request.session.save()
44+
45+
def construct_oidc_logout_url(self, request):
46+
"""
47+
Create the redirect URL for interfacing with the OIDC provider.
48+
49+
Retrieves the necessary parameters from the session and constructs the URL
50+
required to initiate logout with the OpenID Connect provider.
51+
52+
If no ID token is found in the session, the logout flow will not be initiated,
53+
and the method will return the default redirect URL.
54+
55+
The 'state' parameter is generated randomly and persisted in the session to ensure
56+
its integrity during the subsequent callback.
57+
"""
58+
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
59+
60+
if not oidc_logout_endpoint:
61+
return self.redirect_url
62+
63+
reverse_url = reverse("oidc_logout_callback")
64+
id_token = request.session.get("oidc_id_token", None)
65+
66+
if not id_token:
67+
return self.redirect_url
68+
69+
query = {
70+
"id_token_hint": id_token,
71+
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
72+
"post_logout_redirect_uri": absolutify(request, reverse_url),
73+
}
74+
75+
self.persist_state(request, query["state"])
76+
77+
return f"{oidc_logout_endpoint}?{urlencode(query)}"
78+
79+
def post(self, request):
80+
"""
81+
Handle user logout.
82+
83+
If the user is not authenticated, redirects to the default logout URL.
84+
Otherwise, constructs the OIDC logout URL and redirects the user to start
85+
the logout process.
86+
87+
If the user is redirected to the default logout URL, ensure her Django session
88+
is terminated.
89+
"""
90+
logout_url = self.redirect_url
91+
92+
if request.user.is_authenticated:
93+
logout_url = self.construct_oidc_logout_url(request)
94+
95+
# If the user is not redirected to the OIDC provider, ensure logout
96+
if logout_url == self.redirect_url:
97+
auth.logout(request)
98+
99+
return HttpResponseRedirect(logout_url)
100+
101+
102+
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
103+
"""
104+
Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
105+
106+
Handles the callback after logout from the identity provider (OP).
107+
Verifies the state parameter and performs necessary logout actions.
108+
109+
The Django session is maintained during the logout process to ensure the integrity
110+
of the logout flow initiated in the previous step.
111+
"""
112+
113+
http_method_names = ["get"]
114+
115+
def get(self, request):
116+
"""
117+
Handle the logout callback.
118+
119+
If the user is not authenticated, redirects to the default logout URL.
120+
Otherwise, verifies the state parameter and performs necessary logout actions.
121+
"""
122+
if not request.user.is_authenticated:
123+
return HttpResponseRedirect(self.redirect_url)
124+
125+
state = request.GET.get("state")
126+
127+
if state not in request.session.get("oidc_states", {}):
128+
msg = "OIDC callback state not found in session `oidc_states`!"
129+
raise SuspiciousOperation(msg)
130+
131+
del request.session["oidc_states"][state]
132+
request.session.save()
133+
134+
auth.logout(request)
135+
136+
return HttpResponseRedirect(self.redirect_url)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Backend resource server module."""

0 commit comments

Comments
 (0)