Skip to content

Backchannel Logout #1573

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

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Peter Karman
Peter McDonald
Petr Dlouhý
pySilver
Raphael Lullis
Rodney Richardson
Rustem Saiargaliev
Rustem Saiargaliev
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0


## [unreleased]

### Added
* #1506 Support for Wildcard Origin and Redirect URIs
* #1545 Support for OIDC Back-Channel Logout
<!--
### Changed
### Deprecated
Expand Down
23 changes: 23 additions & 0 deletions docs/oidc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,29 @@ This feature has to be enabled separately as it is an extension to the core stan
}


Backchannel Logout Support
~~~~~~~~~~~~~~~~~~~~~~~~~~

`Backchannel Logout`_ is an extension to the core standard which
allows the OP to send direct requests to terminate sessions at the RP.

.. code-block:: python

OAUTH2_PROVIDER = {
# OIDC has to be enabled to use Backchannel logout
"OIDC_ENABLED": True,
"OIDC_ISS_ENDPOINT": "https://idp.example.com", # Required for issuing logout tokens
# Enable and configure Backchannel Logout Support
"OIDC_BACKCHANNEL_LOGOUT_ENABLED": True,
# ... any other settings you want
}

.. _Backchannel Logout: https://openid.net/specs/openid-connect-backchannel-1_0.html

To make use of this, the application being created needs to provide a
valid `backchannel_logout_endpoint`.


Setting up OIDC enabled clients
===============================

Expand Down
10 changes: 10 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ When is set to ``False`` (default) the `OpenID Connect RP-Initiated Logout <http
endpoint is not enabled. OpenID Connect RP-Initiated Logout enables an :term:`Client` (Relying Party)
to request that a :term:`Resource Owner` (End User) is logged out at the :term:`Authorization Server` (OpenID Provider).


OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``True``
Expand Down Expand Up @@ -400,6 +401,15 @@ discovery metadata from ``OIDC_ISS_ENDPOINT`` +
If unset, the default location is used, eg if ``django-oauth-toolkit`` is
mounted at ``/o``, it will be ``<server-address>/o``.

OIDC_BACKCHANNEL_LOGOUT_ENABLED
~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``False``

When is set to ``False`` (default) the `OpenID Connect Backchannel Logout <https://openid.net/specs/openid-connect-backchannel-1_0.html>`_
extension is not enabled. OpenID Connect Backchannel Logout enables the :term:`Authorization Server` (OpenID Provider) to submit a JWT token to an endpoint controlled by the :term:`Client` (Relying Party)
indicating that a session from the :term:`Resource Owner` (End User) has ended.


OIDC_RESPONSE_TYPES_SUPPORTED
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Default::
Expand Down
2 changes: 1 addition & 1 deletion oauth2_provider/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ class DOTConfig(AppConfig):

def ready(self):
# Import checks to ensure they run.
from . import checks # noqa: F401
from . import checks, handlers # noqa: F401
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_backchannel_logout(app_configs, **kwargs):
errors = []

if oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED:
if not callable(oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_HANDLER):
errors.append(checks.Error("OIDC_BACKCHANNEL_LOGOUT_HANDLER must be a callable."))
if not oauth2_settings.OIDC_ISS_ENDPOINT:
errors.append(checks.Error("OIDC_ISS_ENDPOINT must be set to enable OIDC backchannel logout."))

return errors
4 changes: 4 additions & 0 deletions oauth2_provider/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ def __init__(self, description=None):
super().__init__(message)


class BackchannelLogoutRequestError(OIDCError):
error = "backchannel_logout_request_failed"


class InvalidRequestFatalError(OIDCError):
"""
For fatal errors. These are requests with invalid parameter values, missing parameters or otherwise
Expand Down
18 changes: 18 additions & 0 deletions oauth2_provider/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import logging

from django.contrib.auth.signals import user_logged_out
from django.dispatch import receiver

from .settings import oauth2_settings


logger = logging.getLogger(__name__)


@receiver(user_logged_out)
def on_user_logged_out_maybe_send_backchannel_logout(sender, **kwargs):
handler = oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_HANDLER
if not oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED or not callable(handler):
return

handler(user=kwargs["user"])
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2025-06-06 12:42

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('oauth2_provider', '0012_add_token_checksum'),
]

operations = [
migrations.AddField(
model_name='application',
name='backchannel_logout_uri',
field=models.URLField(blank=True, help_text='Backchannel Logout URI where logout tokens will be sent', null=True),
),
]
63 changes: 62 additions & 1 deletion oauth2_provider/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import hashlib
import json
import logging
import time
import uuid
from contextlib import suppress
from datetime import timedelta
from urllib.parse import parse_qsl, urlparse

import requests
from django.apps import apps
from django.conf import settings
from django.contrib.auth.hashers import identify_hasher, make_password
Expand All @@ -14,10 +16,11 @@
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from jwcrypto import jwk
from jwcrypto import jwk, jwt
from jwcrypto.common import base64url_encode
from oauthlib.oauth2.rfc6749 import errors

from .exceptions import BackchannelLogoutRequestError
from .generators import generate_client_id, generate_client_secret
from .scopes import get_scopes_backend
from .settings import oauth2_settings
Expand Down Expand Up @@ -76,6 +79,7 @@ class AbstractApplication(models.Model):
* :attr:`client_secret` Confidential secret issued to the client during
the registration process as described in :rfc:`2.2`
* :attr:`name` Friendly name for the Application
* :attr:`backchannel_logout_uri` Backchannel Logout URI (OIDC-only)
"""

CLIENT_CONFIDENTIAL = "confidential"
Expand Down Expand Up @@ -147,6 +151,9 @@ class AbstractApplication(models.Model):
help_text=_("Allowed origins list to enable CORS, space separated"),
default="",
)
backchannel_logout_uri = models.URLField(
blank=True, null=True, help_text="Backchannel Logout URI where logout tokens will be sent"
)

class Meta:
abstract = True
Expand Down Expand Up @@ -629,6 +636,48 @@ def revoke(self):
"""
self.delete()

def send_backchannel_logout_request(self, ttl=timedelta(minutes=10)):
"""
Send a token to
"""
try:
assert oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED, "Backchannel logout is not enabled"
assert self.application.backchannel_logout_url is not None, (
"URL for backchannel logout not provided by client"
)

issued_at = timezone.now()
expiration_date = issued_at + ttl

claims = {
"iss": oauth2_settings.OIDC_ISS_ENDPOINT,
"sub": str(self.user.id),
"aud": str(self.application.client_id),
"iat": int(issued_at.timestamp()),
"exp": int(expiration_date.timestamp()),
"jti": self.jti,
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
}

# Standard JWT header
header = {"typ": "logout+jwt", "alg": self.application.algorithm}
# RS256 consumers expect a kid in the header for verifying the token
if self.application.algorithm == AbstractApplication.RS256_ALGORITHM:
header["kid"] = self.application.jwk_key.thumbprint()

token = jwt.JWT(
header=json.dumps(header, default=str),
claims=json.dumps(claims, default=str),
)
token.make_signed_token(self.application.jwk_key)

headers = {"Content-Type": "application/x-www-form-urlencoded"}
data = {"logout_token": token.serialize()}
response = requests.post(self.application.backchannel_logout_uri, headers=headers, data=data)
response.raise_for_status()
except (AssertionError, requests.RequestException) as exc:
raise BackchannelLogoutRequestError(str(exc))

@property
def scopes(self):
"""
Expand Down Expand Up @@ -859,3 +908,15 @@ def is_origin_allowed(origin, allowed_origins):
return True

return False


def send_backchannel_logout_requests(user):
"""
Creates logout tokens for all id tokens associated with the user
"""
id_tokens = IDToken.objects.filter(application__backchannel_logout_uri__isnull=False, user=user)
for id_token in id_tokens:
try:
id_token.send_backchannel_logout_request()
except BackchannelLogoutRequestError as exc:
logger.warn(str(exc))
4 changes: 4 additions & 0 deletions oauth2_provider/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@
"client_secret_post",
"client_secret_basic",
],
"OIDC_BACKCHANNEL_LOGOUT_ENABLED": False,
"OIDC_BACKCHANNEL_LOGOUT_HANDLER": "oauth2_provider.models.send_backchannel_logout_requests",
"OIDC_RP_INITIATED_LOGOUT_ENABLED": False,
"OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
"OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS": False,
Expand Down Expand Up @@ -144,7 +146,9 @@
"ACCESS_TOKEN_ADMIN_CLASS",
"GRANT_ADMIN_CLASS",
"ID_TOKEN_ADMIN_CLASS",
"LOGOUT_TOKEN_ADMIN_CLASS",
"REFRESH_TOKEN_ADMIN_CLASS",
"OIDC_BACKCHANNEL_LOGOUT_HANDLER",
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ <h3 class="block-center-heading">{{ application.name }}</h3>
<p><b>{% trans "Allowed Origins" %}</b></p>
<textarea class="input-block-level" readonly>{{ application.allowed_origins }}</textarea>
</li>

<li>
<p><b>{% trans "Backchannel Logout URI" %}</b></p>
<input class="input-block-level" type="text" value="{{ application.backchannel_logout_uri }}" readonly>
</li>
</ul>

<div class="btn-toolbar">
Expand Down
70 changes: 28 additions & 42 deletions oauth2_provider/views/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView

from oauth2_provider.settings import oauth2_settings

from ..models import get_application_model


Expand All @@ -17,33 +19,37 @@ def get_queryset(self):
return get_application_model().objects.filter(user=self.request.user)


class ApplicationRegistration(LoginRequiredMixin, CreateView):
class ApplicationEditorMixin(LoginRequiredMixin):
def get_form_class(self):
"""
Returns the form class for the application model
"""
base_fields = [
"name",
"client_id",
"client_secret",
"hash_client_secret",
"client_type",
"authorization_grant_type",
"redirect_uris",
"post_logout_redirect_uris",
"allowed_origins",
"algorithm",
]

if oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED:
base_fields.append("backchannel_logout_uri")

return modelform_factory(get_application_model(), fields=base_fields)


class ApplicationRegistration(ApplicationEditorMixin, CreateView):
"""
View used to register a new Application for the request.user
"""

template_name = "oauth2_provider/application_registration_form.html"

def get_form_class(self):
"""
Returns the form class for the application model
"""
return modelform_factory(
get_application_model(),
fields=(
"name",
"client_id",
"client_secret",
"hash_client_secret",
"client_type",
"authorization_grant_type",
"redirect_uris",
"post_logout_redirect_uris",
"allowed_origins",
"algorithm",
),
)

def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
Expand Down Expand Up @@ -77,30 +83,10 @@ class ApplicationDelete(ApplicationOwnerIsUserMixin, DeleteView):
template_name = "oauth2_provider/application_confirm_delete.html"


class ApplicationUpdate(ApplicationOwnerIsUserMixin, UpdateView):
class ApplicationUpdate(ApplicationOwnerIsUserMixin, ApplicationEditorMixin, UpdateView):
"""
View used to update an application owned by the request.user
"""

context_object_name = "application"
template_name = "oauth2_provider/application_form.html"

def get_form_class(self):
"""
Returns the form class for the application model
"""
return modelform_factory(
get_application_model(),
fields=(
"name",
"client_id",
"client_secret",
"hash_client_secret",
"client_type",
"authorization_grant_type",
"redirect_uris",
"post_logout_redirect_uris",
"allowed_origins",
"algorithm",
),
)
Loading
Loading