Skip to content

Commit 68b9ce9

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 842a4d5 commit 68b9ce9

File tree

18 files changed

+332
-44
lines changed

18 files changed

+332
-44
lines changed

AUTHORS

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

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [unreleased]
8-
<!--
8+
99
### Added
10+
* #1545 Support for OIDC Back-Channel Logout
11+
12+
<!--
1013
### Changed
1114
### Deprecated
1215
### Removed

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: 16 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,21 @@ 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+
OIDC_BACKCHANNEL_LOGOUT_HANDLER
413+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
414+
Default: ``oauth2_provider.handlers.send_backchannel_logout_request``
415+
416+
Upon logout, the :term:`Authorization Server` (OpenID Provider) will look for all ID Tokens associated with the user on applications that support Backchannel Logout. For every id token that is found, the function defined here will be called. The default function can be used as-is, but if you need to override or customize it somehow (e.g, if you do not want to execute these requests on the same HTTP request-response from the user logout view), you can change this setting to any function that takes an IDToken as the first parameter.
417+
418+
403419
OIDC_RESPONSE_TYPES_SUPPORTED
404420
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
405421
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: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import json
2+
import logging
3+
from datetime import timedelta
4+
5+
import requests
6+
from django.contrib.auth.signals import user_logged_out
7+
from django.dispatch import receiver
8+
from django.utils import timezone
9+
from jwcrypto import jwt
10+
11+
from .exceptions import BackchannelLogoutRequestError
12+
from .models import AbstractApplication, get_id_token_model
13+
from .settings import oauth2_settings
14+
15+
16+
IDToken = get_id_token_model()
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def send_backchannel_logout_request(id_token, *args, **kwargs):
22+
"""
23+
Send a logout token to the applications backchannel logout uri
24+
"""
25+
26+
ttl = kwargs.get("ttl") or timedelta(minutes=10)
27+
28+
try:
29+
assert oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED, "Backchannel logout not enabled"
30+
assert id_token.application.algorithm != AbstractApplication.NO_ALGORITHM, (
31+
"Application must provide signing algorithm"
32+
)
33+
assert id_token.application.backchannel_logout_uri is not None, (
34+
"URL for backchannel logout not provided by client"
35+
)
36+
37+
issued_at = timezone.now()
38+
expiration_date = issued_at + ttl
39+
40+
claims = {
41+
"iss": oauth2_settings.OIDC_ISS_ENDPOINT,
42+
"sub": str(id_token.user.id),
43+
"aud": str(id_token.application.client_id),
44+
"iat": int(issued_at.timestamp()),
45+
"exp": int(expiration_date.timestamp()),
46+
"jti": id_token.jti,
47+
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
48+
}
49+
50+
# Standard JWT header
51+
header = {"typ": "logout+jwt", "alg": id_token.application.algorithm}
52+
53+
# RS256 consumers expect a kid in the header for verifying the token
54+
if id_token.application.algorithm == AbstractApplication.RS256_ALGORITHM:
55+
header["kid"] = id_token.application.jwk_key.thumbprint()
56+
57+
token = jwt.JWT(
58+
header=json.dumps(header, default=str),
59+
claims=json.dumps(claims, default=str),
60+
)
61+
62+
token.make_signed_token(id_token.application.jwk_key)
63+
64+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
65+
data = {"logout_token": token.serialize()}
66+
response = requests.post(id_token.application.backchannel_logout_uri, headers=headers, data=data)
67+
response.raise_for_status()
68+
except (AssertionError, requests.RequestException) as exc:
69+
raise BackchannelLogoutRequestError(str(exc))
70+
71+
72+
@receiver(user_logged_out)
73+
def on_user_logged_out_maybe_send_backchannel_logout(sender, **kwargs):
74+
handler = oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_HANDLER
75+
if not oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED or not callable(handler):
76+
return
77+
78+
user = kwargs["user"]
79+
id_tokens = IDToken.objects.filter(application__backchannel_logout_uri__isnull=False, user=user)
80+
for id_token in id_tokens:
81+
try:
82+
handler(id_token=id_token)
83+
except BackchannelLogoutRequestError as exc:
84+
logger.warn(str(exc))
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class AbstractApplication(models.Model):
7676
* :attr:`client_secret` Confidential secret issued to the client during
7777
the registration process as described in :rfc:`2.2`
7878
* :attr:`name` Friendly name for the Application
79+
* :attr:`backchannel_logout_uri` Backchannel Logout URI (OIDC-only)
7980
"""
8081

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

151155
class Meta:
152156
abstract = True

0 commit comments

Comments
 (0)