Skip to content

Session management #1543

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -5,6 +5,7 @@
from django.utils.cache import patch_vary_headers

from oauth2_provider.models import get_access_token_model
from oauth2_provider.settings import oauth2_settings


log = logging.getLogger(__name__)
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 %}
7 changes: 6 additions & 1 deletion oauth2_provider/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
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"),
Expand All @@ -42,6 +46,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()
8 changes: 7 additions & 1 deletion oauth2_provider/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,11 @@
ScopedProtectedResourceView,
)
from .introspect import IntrospectTokenView
from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView
from .oidc import (
ConnectDiscoveryInfoView,
JwksInfoView,
RPInitiatedLogoutView,
SessionIFrameView,
UserInfoView,
)
from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView
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
Loading