Skip to content
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 @@ -106,6 +106,7 @@ Peter McDonald
Petr Dlouhý
pySilver
@realsuayip
Raphael Lullis
Rodney Richardson
Rustem Saiargaliev
Rustem Saiargaliev
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
* #1637 Support for Django 6.0
* #1545 Support for OIDC Back-Channel Logout

### Removed
* #1636 Remove support for Python 3.8 and 3.9
Expand All @@ -31,6 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Support for Python 3.14 (Django >= 5.2.8)
* #1539 Add device authorization grant support

<!--
### Changed
### Deprecated
### Removed
-->
### Fixed
* #1252 Fix crash when 'client' is in token request body
* #1496 Fix error when Bearer token string is empty but preceded by `Bearer` keyword.
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_uri``.


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

Expand Down
16 changes: 16 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,21 @@ 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_BACKCHANNEL_LOGOUT_HANDLER
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``oauth2_provider.handlers.send_backchannel_logout_request``

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 ``id_token`` as a keyword argument.


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
15 changes: 15 additions & 0 deletions oauth2_provider/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,18 @@ 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 oauth2_settings.OIDC_ENABLED:
errors.append(checks.Error("OIDC_ENABLED must be True to enable OIDC backchannel logout."))
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
5 changes: 5 additions & 0 deletions oauth2_provider/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ def __init__(self, description=None):
super().__init__(message)


class BackchannelLogoutRequestError(OIDCError):
error = "backchannel_logout_request_failed"
description = "Backchannel logout request failed."


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

import requests
from django.contrib.auth.signals import user_logged_out
from django.dispatch import receiver
from django.utils import timezone
from jwcrypto import jwt

from .exceptions import BackchannelLogoutRequestError
from .models import AbstractApplication, get_id_token_model
from .settings import oauth2_settings


IDToken = get_id_token_model()

logger = logging.getLogger(__name__)

BACKCHANNEL_LOGOUT_TIMEOUT = getattr(oauth2_settings, "OIDC_BACKCHANNEL_LOGOUT_TIMEOUT", 5)

Comment on lines +18 to +21
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BACKCHANNEL_LOGOUT_TIMEOUT is read via getattr(oauth2_settings, "OIDC_BACKCHANNEL_LOGOUT_TIMEOUT", 5), but OIDC_BACKCHANNEL_LOGOUT_TIMEOUT is not defined in oauth2_provider.settings.DEFAULTS. Because oauth2_settings.__getattr__ rejects unknown keys, any user-supplied OAUTH2_PROVIDER["OIDC_BACKCHANNEL_LOGOUT_TIMEOUT"] will be ignored and this will always fall back to 5. Add OIDC_BACKCHANNEL_LOGOUT_TIMEOUT to DEFAULTS (and docs if intended as public), and consider reading it inside send_backchannel_logout_request() instead of at import time so overrides/tests take effect.

Copilot uses AI. Check for mistakes.

def send_backchannel_logout_request(id_token, *args, **kwargs):
"""
Send a logout token to the applications backchannel logout uri
"""

ttl = kwargs.get("ttl") or timedelta(minutes=10)

if not oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED:
raise BackchannelLogoutRequestError("Backchannel logout not enabled")

if id_token.application.algorithm == AbstractApplication.NO_ALGORITHM:
raise BackchannelLogoutRequestError("Application must provide signing algorithm")

if not id_token.application.backchannel_logout_uri:
raise BackchannelLogoutRequestError("URL for backchannel logout not provided by client")

if not oauth2_settings.OIDC_ISS_ENDPOINT:
raise BackchannelLogoutRequestError("OIDC_ISS_ENDPOINT is not set")

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

claims = {
"iss": oauth2_settings.OIDC_ISS_ENDPOINT,
"sub": str(id_token.user.pk),
"aud": str(id_token.application.client_id),
"iat": int(issued_at.timestamp()),
"exp": int(expiration_date.timestamp()),
"jti": id_token.jti,
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
Comment on lines +46 to +53
}

# Standard JWT header
header = {"typ": "logout+jwt", "alg": id_token.application.algorithm}

# RS256 consumers expect a kid in the header for verifying the token
if id_token.application.algorithm == AbstractApplication.RS256_ALGORITHM:
header["kid"] = id_token.application.jwk_key.thumbprint()

token = jwt.JWT(
header=json.dumps(header, default=str),
claims=json.dumps(claims, default=str),
)

token.make_signed_token(id_token.application.jwk_key)

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

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send_backchannel_logout_request re-raises BackchannelLogoutRequestError(str(exc)) without exception chaining, which discards the original traceback and makes debugging/observability harder. Re-raise using raise ... from exc so the original request failure context is preserved.

Suggested change
raise BackchannelLogoutRequestError(str(exc))
raise BackchannelLogoutRequestError(str(exc)) from exc

Copilot uses AI. Check for mistakes.

Comment on lines +68 to +81

@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

now = timezone.now()
user = kwargs["user"]

# Get ID tokens for user where Application has backchannel_logout_uri configured
# and scope doesn't contain offline_access (those sessions persist beyond logout)
id_tokens = (
IDToken.objects.filter(user=user, application__backchannel_logout_uri__isnull=False, expires__gt=now)
.exclude(scope__icontains="offline_access")
.exclude(application__backchannel_logout_uri="")
.select_related("application")
.order_by("application", "-expires")
)

# Group by application and send one request per application
applications_notified = set()
for id_token in id_tokens:
if id_token.application not in applications_notified:
applications_notified.add(id_token.application)
try:
handler(id_token=id_token)
except BackchannelLogoutRequestError as exc:
logger.warning(str(exc))
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 5.2 on 2025-06-06 12:42

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("oauth2_provider", "0014_alter_help_text"),
]

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
),
),
]
4 changes: 4 additions & 0 deletions oauth2_provider/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,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 @@ -152,6 +153,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")
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backchannel_logout_uri is stored as a plain URLField but isn’t validated consistently with other URI-like Application fields (e.g., redirect_uris / allowed_origins use AllowedURIValidator + ALLOWED_*SCHEMES in AbstractApplication.clean). Since this value is later used for server-side requests.post, it should be constrained to supported schemes (at least http/https) and validated similarly to avoid storing unsupported/unsafe URIs.

Suggested change
blank=True, null=True, help_text=_("Backchannel Logout URI where logout tokens will be sent")
blank=True,
null=True,
help_text=_("Backchannel Logout URI where logout tokens will be sent"),
validators=[AllowedURIValidator(oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES)],

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should validate to the spec, but not with oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES as they're for redirect uris. Also keep in mind that the spec says

   backchannel_logout_uri
      OPTIONAL.  RP URL that will cause the RP to log itself out when
      sent a Logout Token by the OP.  This URL SHOULD use the "https"
      scheme and MAY contain port, path, and query parameter components;
      however, it MAY use the "http" scheme, provided that the Client
      Type is "confidential", as defined in Section 2.1 of OAuth 2.0
      [RFC6749], and provided the OP allows the use of "http" RP URIs.

and we are permissive with http for non-confidential clients on localhost for development and testing scenarios where certs aren't always easy to use. see: docs/settings.rst ALLOWED_SCHEMES for some examples of the language we've used when allowing this in the past and the implementations for how we warn and communicate about these risks at run time.

)

class Meta:
abstract = True
Expand Down
3 changes: 3 additions & 0 deletions oauth2_provider/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@
"client_secret_post",
"client_secret_basic",
],
"OIDC_BACKCHANNEL_LOGOUT_ENABLED": False,
"OIDC_BACKCHANNEL_LOGOUT_HANDLER": "oauth2_provider.handlers.send_backchannel_logout_request",
"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 @@ -154,6 +156,7 @@
"GRANT_ADMIN_CLASS",
"ID_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>
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When application.backchannel_logout_uri is None, this renders as the literal string "None" in the input’s value attribute. Use |default_if_none:"" (and/or conditional rendering) so the field displays as empty when unset.

Suggested change
<input class="input-block-level" type="text" value="{{ application.backchannel_logout_uri }}" readonly>
<input class="input-block-level" type="text" value="{{ application.backchannel_logout_uri|default_if_none:"" }}" readonly>

Copilot uses AI. Check for mistakes.
</li>
</ul>

<div class="btn-toolbar">
Expand Down
Loading
Loading