Skip to content

Commit

Permalink
First pass at OIDC Session management
Browse files Browse the repository at this point in the history
  • Loading branch information
lullis committed Jan 29, 2025
1 parent 48f4d54 commit 926e384
Show file tree
Hide file tree
Showing 11 changed files with 271 additions and 11 deletions.
33 changes: 33 additions & 0 deletions docs/oidc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,39 @@ token, so you will probably want to reuse that::
claims["color_scheme"] = get_color_scheme(request.user)
return claims


Session Management
==================

The `OpenID Connect Session Management 1.0
<https://openid.net/specs/openid-connect-session-1_0.html>`_
specification defines how to monitor the End-User's login status at
the OpenID Provider on an ongoing basis so that the Relying Party can
log out an End-User who has logged out of the OpenID Provider.

To enable it, you will need to a the
``oauth2_provider.middleware.OIDCSessionManagementMiddleware`` and set
``OIDC_SESSION_MANAGEMENT_ENABLED`` to ``True`` on
``OAUTH2_PROVIDER``. You will also need to provide a string on
``OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY``. This setting is
needed to ensure that the browser state for all unauthenticated users
is fixed and the same even if you are running multiple server
processes :::

import os

MIDDLEWARES = [
# Other middleware...
oauth2_provider.middleware.OIDCSessionManagementMiddleware,
]

OAUTH2_PROVIDER = {
# ... other settings
"OIDC_SESSION_MANAGEMENT_ENABLED": True,
"OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY": os.environ.get("OIDC_DEFAULT_SESSION_KEY"),
}


Customizing the login flow
==========================

Expand Down
19 changes: 19 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,13 @@ Default: ``False``

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

OIDC_SESSION_MANAGEMENT_ENABLED
~~~~~~~~~~~~
Default: ``False``

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



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

OIDC_SESSION_IFRAME_ENDPOINT
~~~~~~~~~~~~~~~~~~~~~~
Default: ``""``

The url of the session frame endpoint. Used to advertise the location of the
endpoint in the OIDC discovery metadata. Changing this does not change the URL
that ``django-oauth-toolkit`` adds for the userinfo endpoint, so if you change
this you must also provide the service at that endpoint.

If unset, the default location is used, eg if ``django-oauth-toolkit`` is
mounted at ``/o/``, it will be ``<server-address>/o/session-iframe/``.

OIDC_RP_INITIATED_LOGOUT_ENABLED
~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``False``
Expand Down
13 changes: 13 additions & 0 deletions oauth2_provider/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,16 @@ def validate_token_configuration(app_configs, **kwargs):
return [checks.Error("The token models are expected to be stored in the same database.")]

return []


@checks.register()
def validate_session_management_configuration(app_configs, **kwargs):
oidc_session_enabled = oauth2_settings.OIDC_SESSION_MANAGEMENT_ENABLED
has_default_key = oauth2_settings.OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY is not None
if oidc_session_enabled and not has_default_key:
return [
checks.Error(
"OIDC Session management is enabled, OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY is required."
)
]
return []
20 changes: 20 additions & 0 deletions oauth2_provider/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.contrib.auth import authenticate
from django.utils.cache import patch_vary_headers

from oauth2_provider import oauth2_settings
from oauth2_provider.models import get_access_token_model


Expand Down Expand Up @@ -63,3 +64,22 @@ def __call__(self, request):
log.exception(e)
response = self.get_response(request)
return response


class OIDCSessionManagementMiddleware:
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
response = self.get_response(request)
if not oauth2_settings.OIDC_SESSION_MANAGEMENT_ENABLED:
return response

cookie_name = oauth2_settings.OIDC_SESSION_MANAGEMENT_COOKIE_NAME
if request.user.is_authenticated:
session_key_bytes = request.session.session_key.encode("utf-8")
hashed_key = hashlib.sha256(session_key_bytes).hexdigest()
response.set_cookie(cookie_name, hashed_key)
else:
response.delete_cookie(cookie_name)
return response
11 changes: 10 additions & 1 deletion oauth2_provider/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@
"ALLOWED_SCHEMES": ["https"],
"ALLOW_URI_WILDCARDS": False,
"OIDC_ENABLED": False,
"OIDC_SESSION_MANAGEMENT_ENABLED": False,
"OIDC_SESSION_MANAGEMENT_COOKIE_NAME": "oidc_ua_agent_state",
"OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY": None,
"OIDC_ISS_ENDPOINT": "",
"OIDC_SESSION_IFRAME_ENDPOINT": "",
"OIDC_USERINFO_ENDPOINT": "",
"OIDC_RSA_PRIVATE_KEY": "",
"OIDC_RSA_PRIVATE_KEYS_INACTIVE": [],
Expand Down Expand Up @@ -169,7 +173,12 @@ def import_from_string(val, setting_name):
try:
return import_string(val)
except ImportError as e:
msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e)
msg = "Could not import %r for setting %r. %s: %s." % (
val,
setting_name,
e.__class__.__name__,
e,
)
raise ImportError(msg)


Expand Down
2 changes: 2 additions & 0 deletions oauth2_provider/templates/oauth2_provider/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
}

</style>
{% block js %}
{% endblock js %}
</head>

<body>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{% extends "oauth2_provider/base.html" %}

{% block title %}Check Session IFrame{% endblock %}

{% block js %}
<script language="JavaScript" type="text/javascript">
async function sha256(message) {
// Encode the message as UTF-8
const msgBuffer = new TextEncoder().encode(message);

// Generate the hash
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

window.addEventListener("message", receiveMessage);

async function receiveMessage(e) {
// e.data has client_id and session_state
if (!e.data || typeof e.data != 'string' || e.data == 'error') {
return;
}

try {
const [clientId, sessionStateImage] = e.data.split(' ');
const [sessionState, salt] = sessionStateImage.split('.');

const userAgentState = getUserAgentState();

const knownImage = await sha256(`${clientId} ${e.origin} ${userAgentState} ${salt}`);

const currentState = `${knownImage}.${salt}`;

const status = sessionState == currentState ? 'unchanged' : 'changed';
e.source.postMessage(status, e.origin);
} catch(err) {
e.source.postMessage('error', e.origin);
}
};


function getUserAgentState() {
const cookieName = "{{ cookie_name }}";

if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, cookieName.length + 1) === (cookieName + '=')) {
return decodeURIComponent(cookie.substring(cookieName.length + 1));
}
}
}
throw new Error('OIDC Session Cookie not set');
}
</script>
{% endblock %}

{% block content %}OIDC Session Management OP Iframe{% endblock content %}
25 changes: 21 additions & 4 deletions oauth2_provider/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,28 @@
management_urlpatterns = [
# Application management views
path("applications/", views.ApplicationList.as_view(), name="list"),
path("applications/register/", views.ApplicationRegistration.as_view(), name="register"),
path(
"applications/register/",
views.ApplicationRegistration.as_view(),
name="register",
),
path("applications/<slug:pk>/", views.ApplicationDetail.as_view(), name="detail"),
path("applications/<slug:pk>/delete/", views.ApplicationDelete.as_view(), name="delete"),
path("applications/<slug:pk>/update/", views.ApplicationUpdate.as_view(), name="update"),
path(
"applications/<slug:pk>/delete/",
views.ApplicationDelete.as_view(),
name="delete",
),
path(
"applications/<slug:pk>/update/",
views.ApplicationUpdate.as_view(),
name="update",
),
# Token management views
path("authorized_tokens/", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"),
path(
"authorized_tokens/",
views.AuthorizedTokensListView.as_view(),
name="authorized-token-list",
),
path(
"authorized_tokens/<slug:pk>/delete/",
views.AuthorizedTokenDeleteView.as_view(),
Expand All @@ -42,6 +58,7 @@
),
path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"),
path("userinfo/", views.UserInfoView.as_view(), name="user-info"),
path("session-iframe/", views.SessionIFrameView.as_view(), name="session-iframe"),
path("logout/", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"),
]

Expand Down
11 changes: 11 additions & 0 deletions oauth2_provider/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import functools
import hashlib

from django.conf import settings
from jwcrypto import jwk

from .settings import oauth2_settings


@functools.lru_cache()
def jwk_from_pem(pem_string):
Expand Down Expand Up @@ -32,3 +35,11 @@ def get_timezone(time_zone):

return pytz.timezone(time_zone)
return zoneinfo.ZoneInfo(time_zone)


def session_management_state_key(request):
"""
Determine value to use as session state.
"""
key = request.session.session_key or str(oauth2_settings.OIDC_SESSION_MANAGEMENT_DEFAULT_SESSION_KEY)
return hashlib.sha256(key.encode("utf-8")).hexdigest()
45 changes: 42 additions & 3 deletions oauth2_provider/views/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import hashlib
import json
import logging
import secrets
from urllib.parse import parse_qsl, urlencode, urlparse

from django.contrib.auth.mixins import LoginRequiredMixin
Expand All @@ -21,6 +22,7 @@
from ..scopes import get_scopes_backend
from ..settings import oauth2_settings
from ..signals import app_authorized
from ..utils import session_management_state_key
from .mixins import OAuthLibMixin


Expand Down Expand Up @@ -135,11 +137,43 @@ def form_valid(self, form):

try:
uri, headers, body, status = self.create_authorization_response(
request=self.request, scopes=scopes, credentials=credentials, allow=allow
request=self.request,
scopes=scopes,
credentials=credentials,
allow=allow,
)
except OAuthToolkitError as error:
return self.error_response(error, application)

if oauth2_settings.OIDC_SESSION_MANAGEMENT_ENABLED:
# https://openid.net/specs/openid-connect-session-1_0.html#CreatingUpdatingSessions

# When the OP supports session management, it MUST also
# return the Session State as an additional session_state
# parameter in the Authentication Response, the value is
# based on a salted cryptographic hash of Client ID,
# origin URL, and OP User Agent state.
parsed = urlparse(uri)
client_origin = f"{parsed.scheme}://{parsed.netloc}"

# Create random salt.
salt = secrets.token_urlsafe(16)
encoded = " ".join(
[
self.client.client_id,
client_origin,
session_management_state_key(self.request),
salt,
]
).encode("utf-8")
hashed = hashlib.sha256(encoded)
session_state = f"{hashed.hexdigest()}.{salt}"

# Add the session_state parameter to the query string
qs = dict(parse_qsl(parsed.query))
qs["session_state"] = session_state
uri = parsed._replace(query=urlencode(qs)).geturl()

self.success_url = uri
log.debug("Success url for the request: {0}".format(self.success_url))
return self.redirect(self.success_url, application)
Expand Down Expand Up @@ -197,15 +231,20 @@ def get(self, request, *args, **kwargs):
# are already approved.
if application.skip_authorization:
uri, headers, body, status = self.create_authorization_response(
request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True
request=self.request,
scopes=" ".join(scopes),
credentials=credentials,
allow=True,
)
return self.redirect(uri, application)

elif require_approval == "auto":
tokens = (
get_access_token_model()
.objects.filter(
user=request.user, application=kwargs["application"], expires__gt=timezone.now()
user=request.user,
application=kwargs["application"],
expires__gt=timezone.now(),
)
.all()
)
Expand Down
Loading

0 comments on commit 926e384

Please sign in to comment.