Skip to content

Commit 81d83ec

Browse files
committed
Backchannel Logout
- Add migration to add backchannel logout support - Add parameters related to backchannel logout on OIDC Discovery View - Change application creation and update form - Change template that renders information about the application
1 parent 121abd4 commit 81d83ec

18 files changed

+312
-44
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ Peter Karman
9999
Peter McDonald
100100
Petr Dlouhý
101101
pySilver
102+
Raphael Lullis
102103
Rodney Richardson
103104
Rustem Saiargaliev
104105
Rustem Saiargaliev

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77

88
## [unreleased]
9+
910
### Added
1011
* #1506 Support for Wildcard Origin and Redirect URIs
12+
* #1545 Support for OIDC Back-Channel Logout
1113
<!--
1214
### Changed
1315
### Deprecated

docs/oidc.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,29 @@ This feature has to be enabled separately as it is an extension to the core stan
169169
}
170170
171171
172+
Backchannel Logout Support
173+
~~~~~~~~~~~~~~~~~~~~~~~~~~
174+
175+
`Backchannel Logout`_ is an extension to the core standard which
176+
allows the OP to send direct requests to terminate sessions at the RP.
177+
178+
.. code-block:: python
179+
180+
OAUTH2_PROVIDER = {
181+
# OIDC has to be enabled to use Backchannel logout
182+
"OIDC_ENABLED": True,
183+
"OIDC_ISS_ENDPOINT": "https://idp.example.com", # Required for issuing logout tokens
184+
# Enable and configure Backchannel Logout Support
185+
"OIDC_BACKCHANNEL_LOGOUT_ENABLED": True,
186+
# ... any other settings you want
187+
}
188+
189+
.. _Backchannel Logout: https://openid.net/specs/openid-connect-backchannel-1_0.html
190+
191+
To make use of this, the application being created needs to provide a
192+
valid `backchannel_logout_endpoint`.
193+
194+
172195
Setting up OIDC enabled clients
173196
===============================
174197

docs/settings.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ When is set to ``False`` (default) the `OpenID Connect RP-Initiated Logout <http
361361
endpoint is not enabled. OpenID Connect RP-Initiated Logout enables an :term:`Client` (Relying Party)
362362
to request that a :term:`Resource Owner` (End User) is logged out at the :term:`Authorization Server` (OpenID Provider).
363363

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

404+
OIDC_BACKCHANNEL_LOGOUT_ENABLED
405+
~~~~~~~~~~~~~~~~~~~~~~~~
406+
Default: ``False``
407+
408+
When is set to ``False`` (default) the `OpenID Connect Backchannel Logout <https://openid.net/specs/openid-connect-backchannel-1_0.html>`_
409+
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)
410+
indicating that a session from the :term:`Resource Owner` (End User) has ended.
411+
412+
403413
OIDC_RESPONSE_TYPES_SUPPORTED
404414
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
405415
Default::

oauth2_provider/apps.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ class DOTConfig(AppConfig):
77

88
def ready(self):
99
# Import checks to ensure they run.
10-
from . import checks # noqa: F401
10+
from . import checks, handlers # noqa: F401

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_backchannel_logout(app_configs, **kwargs):
33+
errors = []
34+
35+
if oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED:
36+
if not callable(oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_HANDLER):
37+
errors.append(checks.Error("OIDC_BACKCHANNEL_LOGOUT_HANDLER must be a callable."))
38+
if not oauth2_settings.OIDC_ISS_ENDPOINT:
39+
errors.append(checks.Error("OIDC_ISS_ENDPOINT must be set to enable OIDC backchannel logout."))
40+
41+
return errors

oauth2_provider/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ def __init__(self, description=None):
3535
super().__init__(message)
3636

3737

38+
class BackchannelLogoutRequestError(OIDCError):
39+
error = "backchannel_logout_request_failed"
40+
41+
3842
class InvalidRequestFatalError(OIDCError):
3943
"""
4044
For fatal errors. These are requests with invalid parameter values, missing parameters or otherwise

oauth2_provider/handlers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import logging
2+
3+
from django.contrib.auth.signals import user_logged_out
4+
from django.dispatch import receiver
5+
6+
from .settings import oauth2_settings
7+
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
@receiver(user_logged_out)
13+
def on_user_logged_out_maybe_send_backchannel_logout(sender, **kwargs):
14+
handler = oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_HANDLER
15+
if not oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED or not callable(handler):
16+
return
17+
18+
handler(user=kwargs["user"])
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2 on 2025-06-06 12:42
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('oauth2_provider', '0012_add_token_checksum'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='application',
15+
name='backchannel_logout_uri',
16+
field=models.URLField(blank=True, help_text='Backchannel Logout URI where logout tokens will be sent', null=True),
17+
),
18+
]

oauth2_provider/models.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import hashlib
2+
import json
23
import logging
34
import time
45
import uuid
56
from contextlib import suppress
67
from datetime import timedelta
78
from urllib.parse import parse_qsl, urlparse
89

10+
import requests
911
from django.apps import apps
1012
from django.conf import settings
1113
from django.contrib.auth.hashers import identify_hasher, make_password
@@ -14,10 +16,11 @@
1416
from django.urls import reverse
1517
from django.utils import timezone
1618
from django.utils.translation import gettext_lazy as _
17-
from jwcrypto import jwk
19+
from jwcrypto import jwk, jwt
1820
from jwcrypto.common import base64url_encode
1921
from oauthlib.oauth2.rfc6749 import errors
2022

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

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

151158
class Meta:
152159
abstract = True
@@ -629,6 +636,53 @@ def revoke(self):
629636
"""
630637
self.delete()
631638

639+
def send_backchannel_logout_request(self, ttl=timedelta(minutes=10)):
640+
"""
641+
Send a logout token to the applications backchannel logout uri
642+
"""
643+
try:
644+
assert oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED, "Backchannel logout not enabled"
645+
assert self.application.algorithm != AbstractApplication.NO_ALGORITHM, (
646+
"Application must provide signing algorithm"
647+
)
648+
assert self.application.backchannel_logout_uri is not None, (
649+
"URL for backchannel logout not provided by client"
650+
)
651+
652+
issued_at = timezone.now()
653+
expiration_date = issued_at + ttl
654+
655+
claims = {
656+
"iss": oauth2_settings.OIDC_ISS_ENDPOINT,
657+
"sub": str(self.user.id),
658+
"aud": str(self.application.client_id),
659+
"iat": int(issued_at.timestamp()),
660+
"exp": int(expiration_date.timestamp()),
661+
"jti": self.jti,
662+
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
663+
}
664+
665+
# Standard JWT header
666+
header = {"typ": "logout+jwt", "alg": self.application.algorithm}
667+
668+
# RS256 consumers expect a kid in the header for verifying the token
669+
if self.application.algorithm == AbstractApplication.RS256_ALGORITHM:
670+
header["kid"] = self.application.jwk_key.thumbprint()
671+
672+
token = jwt.JWT(
673+
header=json.dumps(header, default=str),
674+
claims=json.dumps(claims, default=str),
675+
)
676+
677+
token.make_signed_token(self.application.jwk_key)
678+
679+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
680+
data = {"logout_token": token.serialize()}
681+
response = requests.post(self.application.backchannel_logout_uri, headers=headers, data=data)
682+
response.raise_for_status()
683+
except (AssertionError, requests.RequestException) as exc:
684+
raise BackchannelLogoutRequestError(str(exc))
685+
632686
@property
633687
def scopes(self):
634688
"""
@@ -859,3 +913,15 @@ def is_origin_allowed(origin, allowed_origins):
859913
return True
860914

861915
return False
916+
917+
918+
def send_backchannel_logout_requests(user):
919+
"""
920+
Creates logout tokens for all id tokens associated with the user
921+
"""
922+
id_tokens = IDToken.objects.filter(application__backchannel_logout_uri__isnull=False, user=user)
923+
for id_token in id_tokens:
924+
try:
925+
id_token.send_backchannel_logout_request()
926+
except BackchannelLogoutRequestError as exc:
927+
logger.warn(str(exc))

0 commit comments

Comments
 (0)