Skip to content

Commit cd68405

Browse files
committed
First pass at OIDC Session management
1 parent 172c636 commit cd68405

File tree

12 files changed

+263
-9
lines changed

12 files changed

+263
-9
lines changed

docs/oidc.rst

+33
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,39 @@ token, so you will probably want to reuse that::
381381
claims["color_scheme"] = get_color_scheme(request.user)
382382
return claims
383383

384+
385+
Session Management
386+
==================
387+
388+
The `OpenID Connect Session Management 1.0
389+
<https://openid.net/specs/openid-connect-session-1_0.html>`_
390+
specification defines how to monitor the End-User's login status at
391+
the OpenID Provider on an ongoing basis so that the Relying Party can
392+
log out an End-User who has logged out of the OpenID Provider.
393+
394+
To enable it, you will need to a the
395+
``oauth2_provider.middleware.OIDCSessionManagementMiddleware`` and set
396+
``OIDC_SESSION_MANAGEMENT_ENABLED`` to ``True`` on
397+
``OAUTH2_PROVIDER``. You will also need to provide a string on
398+
``OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY``. This setting is
399+
needed to ensure that the browser state for all unauthenticated users
400+
is fixed and the same even if you are running multiple server
401+
processes :::
402+
403+
import os
404+
405+
MIDDLEWARES = [
406+
# Other middleware...
407+
oauth2_provider.middleware.OIDCSessionManagementMiddleware,
408+
]
409+
410+
OAUTH2_PROVIDER = {
411+
# ... other settings
412+
"OIDC_SESSION_MANAGEMENT_ENABLED": True,
413+
"OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY": os.environ.get("OIDC_DEFAULT_SESSION_KEY"),
414+
}
415+
416+
384417
Customizing the login flow
385418
==========================
386419

docs/settings.rst

+19
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,13 @@ Default: ``False``
340340

341341
Whether or not :doc:`oidc` support is enabled.
342342

343+
OIDC_SESSION_MANAGEMENT_ENABLED
344+
~~~~~~~~~~~~
345+
Default: ``False``
346+
347+
Whether or not :doc:`oidc` support is enabled.
348+
349+
343350

344351
OIDC_RSA_PRIVATE_KEY
345352
~~~~~~~~~~~~~~~~~~~~
@@ -379,6 +386,18 @@ this you must also provide the service at that endpoint.
379386
If unset, the default location is used, eg if ``django-oauth-toolkit`` is
380387
mounted at ``/o/``, it will be ``<server-address>/o/userinfo/``.
381388

389+
OIDC_SESSION_IFRAME_ENDPOINT
390+
~~~~~~~~~~~~~~~~~~~~~~
391+
Default: ``""``
392+
393+
The url of the session frame endpoint. Used to advertise the location of the
394+
endpoint in the OIDC discovery metadata. Changing this does not change the URL
395+
that ``django-oauth-toolkit`` adds for the userinfo endpoint, so if you change
396+
this you must also provide the service at that endpoint.
397+
398+
If unset, the default location is used, eg if ``django-oauth-toolkit`` is
399+
mounted at ``/o/``, it will be ``<server-address>/o/session-iframe/``.
400+
382401
OIDC_RP_INITIATED_LOGOUT_ENABLED
383402
~~~~~~~~~~~~~~~~~~~~~~~~
384403
Default: ``False``

oauth2_provider/checks.py

+13
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,16 @@ def validate_token_configuration(app_configs, **kwargs):
2626
return [checks.Error("The token models are expected to be stored in the same database.")]
2727

2828
return []
29+
30+
31+
@checks.register()
32+
def validate_session_management_configuration(app_configs, **kwargs):
33+
oidc_session_enabled = oauth2_settings.OIDC_SESSION_MANAGEMENT_ENABLED
34+
has_default_key = oauth2_settings.OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY is not None
35+
if oidc_session_enabled and not has_default_key:
36+
return [
37+
checks.Error(
38+
"OIDC Session management is enabled, OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY is required."
39+
)
40+
]
41+
return []

oauth2_provider/middleware.py

+20
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.utils.cache import patch_vary_headers
66

77
from oauth2_provider.models import get_access_token_model
8+
from oauth2_provider.settings import oauth2_settings
89

910

1011
log = logging.getLogger(__name__)
@@ -63,3 +64,22 @@ def __call__(self, request):
6364
log.exception(e)
6465
response = self.get_response(request)
6566
return response
67+
68+
69+
class OIDCSessionManagementMiddleware:
70+
def __init__(self, get_response):
71+
self.get_response = get_response
72+
73+
def __call__(self, request):
74+
response = self.get_response(request)
75+
if not oauth2_settings.OIDC_SESSION_MANAGEMENT_ENABLED:
76+
return response
77+
78+
cookie_name = oauth2_settings.OIDC_SESSION_MANAGEMENT_COOKIE_NAME
79+
if request.user.is_authenticated:
80+
session_key_bytes = request.session.session_key.encode("utf-8")
81+
hashed_key = hashlib.sha256(session_key_bytes).hexdigest()
82+
response.set_cookie(cookie_name, hashed_key)
83+
else:
84+
response.delete_cookie(cookie_name)
85+
return response

oauth2_provider/settings.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@
7373
"ALLOWED_SCHEMES": ["https"],
7474
"ALLOW_URI_WILDCARDS": False,
7575
"OIDC_ENABLED": False,
76+
"OIDC_SESSION_MANAGEMENT_ENABLED": False,
77+
"OIDC_SESSION_MANAGEMENT_COOKIE_NAME": "oidc_ua_agent_state",
78+
"OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY": None,
7679
"OIDC_ISS_ENDPOINT": "",
80+
"OIDC_SESSION_IFRAME_ENDPOINT": "",
7781
"OIDC_USERINFO_ENDPOINT": "",
7882
"OIDC_RSA_PRIVATE_KEY": "",
7983
"OIDC_RSA_PRIVATE_KEYS_INACTIVE": [],
@@ -169,7 +173,12 @@ def import_from_string(val, setting_name):
169173
try:
170174
return import_string(val)
171175
except ImportError as e:
172-
msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e)
176+
msg = "Could not import %r for setting %r. %s: %s." % (
177+
val,
178+
setting_name,
179+
e.__class__.__name__,
180+
e,
181+
)
173182
raise ImportError(msg)
174183

175184

oauth2_provider/templates/oauth2_provider/base.html

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
}
3636

3737
</style>
38+
{% block js %}
39+
{% endblock js %}
3840
</head>
3941

4042
<body>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{% extends "oauth2_provider/base.html" %}
2+
3+
{% block title %}Check Session IFrame{% endblock %}
4+
5+
{% block js %}
6+
<script language="JavaScript" type="text/javascript">
7+
async function sha256(message) {
8+
// Encode the message as UTF-8
9+
const msgBuffer = new TextEncoder().encode(message);
10+
11+
// Generate the hash
12+
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
13+
const hashArray = Array.from(new Uint8Array(hashBuffer));
14+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
15+
}
16+
17+
window.addEventListener("message", receiveMessage);
18+
19+
async function receiveMessage(e) {
20+
// e.data has client_id and session_state
21+
if (!e.data || typeof e.data != 'string' || e.data == 'error') {
22+
return;
23+
}
24+
25+
try {
26+
const [clientId, sessionStateImage] = e.data.split(' ');
27+
const [sessionState, salt] = sessionStateImage.split('.');
28+
29+
const userAgentState = getUserAgentState();
30+
31+
const knownImage = await sha256(`${clientId} ${e.origin} ${userAgentState} ${salt}`);
32+
33+
const currentState = `${knownImage}.${salt}`;
34+
35+
const status = sessionState == currentState ? 'unchanged' : 'changed';
36+
e.source.postMessage(status, e.origin);
37+
} catch(err) {
38+
e.source.postMessage('error', e.origin);
39+
}
40+
};
41+
42+
43+
function getUserAgentState() {
44+
const cookieName = "{{ cookie_name }}";
45+
46+
if (document.cookie && document.cookie !== '') {
47+
const cookies = document.cookie.split(';');
48+
for (var i = 0; i < cookies.length; i++) {
49+
const cookie = cookies[i].trim();
50+
// Does this cookie string begin with the name we want?
51+
if (cookie.substring(0, cookieName.length + 1) === (cookieName + '=')) {
52+
return decodeURIComponent(cookie.substring(cookieName.length + 1));
53+
}
54+
}
55+
}
56+
throw new Error('OIDC Session Cookie not set');
57+
}
58+
</script>
59+
{% endblock %}
60+
61+
{% block content %}OIDC Session Management OP Iframe{% endblock content %}

oauth2_provider/urls.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
management_urlpatterns = [
1818
# Application management views
1919
path("applications/", views.ApplicationList.as_view(), name="list"),
20-
path("applications/register/", views.ApplicationRegistration.as_view(), name="register"),
20+
path(
21+
"applications/register/",
22+
views.ApplicationRegistration.as_view(),
23+
name="register",
24+
),
2125
path("applications/<slug:pk>/", views.ApplicationDetail.as_view(), name="detail"),
2226
path("applications/<slug:pk>/delete/", views.ApplicationDelete.as_view(), name="delete"),
2327
path("applications/<slug:pk>/update/", views.ApplicationUpdate.as_view(), name="update"),
@@ -42,6 +46,7 @@
4246
),
4347
path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"),
4448
path("userinfo/", views.UserInfoView.as_view(), name="user-info"),
49+
path("session-iframe/", views.SessionIFrameView.as_view(), name="session-iframe"),
4550
path("logout/", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"),
4651
]
4752

oauth2_provider/utils.py

+11
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import functools
2+
import hashlib
23

34
from django.conf import settings
45
from jwcrypto import jwk
56

7+
from .settings import oauth2_settings
8+
69

710
@functools.lru_cache()
811
def jwk_from_pem(pem_string):
@@ -32,3 +35,11 @@ def get_timezone(time_zone):
3235

3336
return pytz.timezone(time_zone)
3437
return zoneinfo.ZoneInfo(time_zone)
38+
39+
40+
def session_management_state_key(request):
41+
"""
42+
Determine value to use as session state.
43+
"""
44+
key = request.session.session_key or str(oauth2_settings.OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY)
45+
return hashlib.sha256(key.encode("utf-8")).hexdigest()

oauth2_provider/views/__init__.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,11 @@
1515
ScopedProtectedResourceView,
1616
)
1717
from .introspect import IntrospectTokenView
18-
from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView
18+
from .oidc import (
19+
ConnectDiscoveryInfoView,
20+
JwksInfoView,
21+
RPInitiatedLogoutView,
22+
SessionIFrameView,
23+
UserInfoView,
24+
)
1925
from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView

oauth2_provider/views/base.py

+42-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import hashlib
22
import json
33
import logging
4+
import secrets
45
from urllib.parse import parse_qsl, urlencode, urlparse
56

67
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -21,6 +22,7 @@
2122
from ..scopes import get_scopes_backend
2223
from ..settings import oauth2_settings
2324
from ..signals import app_authorized
25+
from ..utils import session_management_state_key
2426
from .mixins import OAuthLibMixin
2527

2628

@@ -135,11 +137,43 @@ def form_valid(self, form):
135137

136138
try:
137139
uri, headers, body, status = self.create_authorization_response(
138-
request=self.request, scopes=scopes, credentials=credentials, allow=allow
140+
request=self.request,
141+
scopes=scopes,
142+
credentials=credentials,
143+
allow=allow,
139144
)
140145
except OAuthToolkitError as error:
141146
return self.error_response(error, application)
142147

148+
if oauth2_settings.OIDC_SESSION_MANAGEMENT_ENABLED:
149+
# https://openid.net/specs/openid-connect-session-1_0.html#CreatingUpdatingSessions
150+
151+
# When the OP supports session management, it MUST also
152+
# return the Session State as an additional session_state
153+
# parameter in the Authentication Response, the value is
154+
# based on a salted cryptographic hash of Client ID,
155+
# origin URL, and OP User Agent state.
156+
parsed = urlparse(uri)
157+
client_origin = f"{parsed.scheme}://{parsed.netloc}"
158+
159+
# Create random salt.
160+
salt = secrets.token_urlsafe(16)
161+
encoded = " ".join(
162+
[
163+
self.client.client_id,
164+
client_origin,
165+
session_management_state_key(self.request),
166+
salt,
167+
]
168+
).encode("utf-8")
169+
hashed = hashlib.sha256(encoded)
170+
session_state = f"{hashed.hexdigest()}.{salt}"
171+
172+
# Add the session_state parameter to the query string
173+
qs = dict(parse_qsl(parsed.query))
174+
qs["session_state"] = session_state
175+
uri = parsed._replace(query=urlencode(qs)).geturl()
176+
143177
self.success_url = uri
144178
log.debug("Success url for the request: {0}".format(self.success_url))
145179
return self.redirect(self.success_url, application)
@@ -197,15 +231,20 @@ def get(self, request, *args, **kwargs):
197231
# are already approved.
198232
if application.skip_authorization:
199233
uri, headers, body, status = self.create_authorization_response(
200-
request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True
234+
request=self.request,
235+
scopes=" ".join(scopes),
236+
credentials=credentials,
237+
allow=True,
201238
)
202239
return self.redirect(uri, application)
203240

204241
elif require_approval == "auto":
205242
tokens = (
206243
get_access_token_model()
207244
.objects.filter(
208-
user=request.user, application=kwargs["application"], expires__gt=timezone.now()
245+
user=request.user,
246+
application=kwargs["application"],
247+
expires__gt=timezone.now(),
209248
)
210249
.all()
211250
)

0 commit comments

Comments
 (0)