Skip to content

Commit cfa4d05

Browse files
committed
To enable it, user must add OIDC_SESSION_MANAGEMENT_ENABLED and provide OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY on OAUTH2_PROVIDER settings, and add the proper middleware. This PR contains: - change in AuthorizationView to return 'session_state' parameter in authentication response - a SessionIFrameView as part of the OIDC views, which renders the content of the iframe used by RPs to keep track of session state changes. - middleware that sets the cookie - Documentation - Test for the changed authentication view
1 parent 94dd076 commit cfa4d05

File tree

17 files changed

+335
-17
lines changed

17 files changed

+335
-17
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Peter McDonald
103103
Petr Dlouhý
104104
pySilver
105105
@realsuayip
106+
Raphael Lullis
106107
Rodney Richardson
107108
Rustem Saiargaliev
108109
Rustem Saiargaliev

CHANGELOG.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
### Added
1010
* Support for Django 5.2
1111
* Support for Python 3.14 (Django >= 5.2.8)
12-
* #1539 Add device authorization grant support
13-
12+
* Support for OIDC Session Management (https://openid.net/specs/openid-connect-session-1_0.html)
1413

1514
<!--
1615
### Changed

docs/oidc.rst

Lines changed: 33 additions & 0 deletions
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 add
395+
``oauth2_provider.middleware.OIDCSessionManagementMiddleware`` to MIDDLEWARES 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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,12 @@ Default: ``False``
315315

316316
Whether or not :doc:`oidc` support is enabled.
317317

318+
OIDC_SESSION_MANAGEMENT_ENABLED
319+
~~~~~~~~~~~~
320+
Default: ``False``
321+
322+
Whether or not :doc:`oidc` support is enabled.
323+
318324
OIDC_RSA_PRIVATE_KEY
319325
~~~~~~~~~~~~~~~~~~~~
320326
Default: ``""``
@@ -353,6 +359,18 @@ this you must also provide the service at that endpoint.
353359
If unset, the default location is used, eg if ``django-oauth-toolkit`` is
354360
mounted at ``/o/``, it will be ``<server-address>/o/userinfo/``.
355361

362+
OIDC_SESSION_IFRAME_ENDPOINT
363+
~~~~~~~~~~~~~~~~~~~~~~
364+
Default: ``""``
365+
366+
The url of the session frame endpoint. Used to advertise the location of the
367+
endpoint in the OIDC discovery metadata. Changing this does not change the URL
368+
that ``django-oauth-toolkit`` adds for the userinfo endpoint, so if you change
369+
this you must also provide the service at that endpoint.
370+
371+
If unset, the default location is used, eg if ``django-oauth-toolkit`` is
372+
mounted at ``/o/``, it will be ``<server-address>/o/session-iframe/``.
373+
356374
OIDC_RP_INITIATED_LOGOUT_ENABLED
357375
~~~~~~~~~~~~~~~~~~~~~~~~
358376
Default: ``False``

oauth2_provider/checks.py

Lines changed: 13 additions & 0 deletions
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

Lines changed: 20 additions & 0 deletions
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__)
@@ -64,3 +65,22 @@ def __call__(self, request):
6465
log.exception(e)
6566
response = self.get_response(request)
6667
return response
68+
69+
70+
class OIDCSessionManagementMiddleware:
71+
def __init__(self, get_response):
72+
self.get_response = get_response
73+
74+
def __call__(self, request):
75+
response = self.get_response(request)
76+
if not oauth2_settings.OIDC_SESSION_MANAGEMENT_ENABLED:
77+
return response
78+
79+
cookie_name = oauth2_settings.OIDC_SESSION_MANAGEMENT_COOKIE_NAME
80+
if request.user.is_authenticated:
81+
session_key_bytes = request.session.session_key.encode("utf-8")
82+
hashed_key = hashlib.sha256(session_key_bytes).hexdigest()
83+
response.set_cookie(cookie_name, hashed_key)
84+
else:
85+
response.delete_cookie(cookie_name)
86+
return response

oauth2_provider/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@
8282
"ALLOWED_SCHEMES": ["https"],
8383
"ALLOW_URI_WILDCARDS": False,
8484
"OIDC_ENABLED": False,
85+
"OIDC_SESSION_MANAGEMENT_ENABLED": False,
86+
"OIDC_SESSION_MANAGEMENT_COOKIE_NAME": "oidc_ua_agent_state",
87+
"OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY": None,
8588
"OIDC_ISS_ENDPOINT": "",
89+
"OIDC_SESSION_IFRAME_ENDPOINT": "",
8690
"OIDC_USERINFO_ENDPOINT": "",
8791
"OIDC_RSA_PRIVATE_KEY": "",
8892
"OIDC_RSA_PRIVATE_KEYS_INACTIVE": [],

oauth2_provider/templates/oauth2_provider/base.html

Lines changed: 2 additions & 0 deletions
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>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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+
let userAgentState
30+
try {
31+
userAgentState = getUserAgentState()
32+
}
33+
catch (err) {
34+
userAgentState = ''
35+
}
36+
const knownImage = await sha256(`${clientId} ${e.origin} ${userAgentState} ${salt}`)
37+
38+
const status = sessionState == knownImage ? 'unchanged' : 'changed'
39+
e.source.postMessage(status, e.origin)
40+
} catch(err) {
41+
e.source.postMessage(`error: ${err}`, e.origin)
42+
}
43+
}
44+
45+
46+
function getUserAgentState() {
47+
const cookieName = "{{ cookie_name }}"
48+
if (document.cookie && document.cookie !== '') {
49+
const cookies = document.cookie.split(';')
50+
for (var i = 0; i < cookies.length; i++) {
51+
const cookie = cookies[i].trim()
52+
// Does this cookie string begin with the name we want?
53+
if (cookie.substring(0, cookieName.length + 1) === (cookieName + '=')) {
54+
return decodeURIComponent(cookie.substring(cookieName.length + 1))
55+
}
56+
}
57+
}
58+
throw new Error('OIDC Session Cookie not set')
59+
}
60+
</script>
61+
{% endblock %}
62+
63+
{% block content %}OIDC Session Management OP Iframe{% endblock content %}

oauth2_provider/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
),
5555
path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"),
5656
path("userinfo/", views.UserInfoView.as_view(), name="user-info"),
57+
path("session-iframe/", views.SessionIFrameView.as_view(), name="session-iframe"),
5758
path("logout/", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"),
5859
]
5960

0 commit comments

Comments
 (0)