Skip to content

Commit e47ff5b

Browse files
committed
Backchannel Logout: Models and Views
- Add migration to add backchannel logout support - Add model for Logout Token - Add admin for Logout Token - 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 6507e66 commit e47ff5b

File tree

12 files changed

+291
-44
lines changed

12 files changed

+291
-44
lines changed

oauth2_provider/admin.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ class IDTokenAdmin(admin.ModelAdmin):
5151
list_select_related = ("application", "user")
5252

5353

54+
class LogoutTokenAdmin(admin.ModelAdmin):
55+
list_display = ("user", "application")
56+
raw_id_fields = ("user",)
57+
search_fields = ("user__email",) if has_email else ()
58+
list_filter = ("application",)
59+
list_select_related = ("application", "user")
60+
61+
5462
class RefreshTokenAdmin(admin.ModelAdmin):
5563
list_display = ("token", "user", "application")
5664
raw_id_fields = ("user", "access_token")

oauth2_provider/apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ class DOTConfig(AppConfig):
88
def ready(self):
99
# Import checks to ensure they run.
1010
from . import checks # noqa: F401
11+
from . import handlers # noqa

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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import logging
2+
3+
from django.contrib.auth.signals import user_logged_out
4+
from django.dispatch import receiver
5+
6+
from .exceptions import BackchannelLogoutRequestError
7+
from .settings import oauth2_settings
8+
from .models import get_logout_token_model, get_application_model, get_access_token_model
9+
10+
11+
logger = logging.getLogger(__name__)
12+
13+
LogoutToken = get_logout_token_model()
14+
Application = get_application_model()
15+
AccessToken = get_access_token_model()
16+
17+
18+
@receiver(user_logged_out)
19+
def on_user_logged_out_maybe_send_backchannel_logout(sender, **kw):
20+
if not oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED:
21+
return
22+
23+
request = kw["request"]
24+
user = kw["user"]
25+
26+
applications = Application.objects.exclude(backchannel_logout_uri__isnull=True)
27+
for application in applications:
28+
logout_token = LogoutToken.objects.create(
29+
user=user, session_key=request.session.session_key, application=application
30+
)
31+
try:
32+
logout_token.send_backchannel_logout_request()
33+
except BackchannelLogoutRequestError as exc:
34+
logger.warn(str(exc))
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Generated by Django 5.2 on 2025-05-05 02:41
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
from oauth2_provider.settings import oauth2_settings
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
("oauth2_provider", "0012_add_token_checksum"),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="application",
18+
name="backchannel_logout_session_required",
19+
field=models.BooleanField(default=False, help_text="Include SID claim in logout token?"),
20+
),
21+
migrations.AddField(
22+
model_name="application",
23+
name="backchannel_logout_uri",
24+
field=models.URLField(
25+
blank=True, help_text="Backchannel Logout URI where logout tokens will be sent", null=True
26+
),
27+
),
28+
migrations.CreateModel(
29+
name="LogoutToken",
30+
fields=[
31+
("id", models.BigAutoField(primary_key=True, serialize=False)),
32+
("created", models.DateTimeField(auto_now_add=True)),
33+
(
34+
"application",
35+
models.ForeignKey(
36+
on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL
37+
),
38+
),
39+
(
40+
"id_token",
41+
models.OneToOneField(
42+
null=True,
43+
on_delete=django.db.models.deletion.SET_NULL,
44+
to=oauth2_settings.ID_TOKEN_MODEL,
45+
),
46+
),
47+
(
48+
"user",
49+
models.ForeignKey(
50+
on_delete=django.db.models.deletion.CASCADE,
51+
related_name="%(app_label)s_%(class)s",
52+
to=settings.AUTH_USER_MODEL,
53+
),
54+
),
55+
],
56+
options={
57+
"abstract": False,
58+
"swappable": "OAUTH2_PROVIDER_LOGOUT_TOKEN_MODEL",
59+
},
60+
),
61+
]

oauth2_provider/models.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import time
44
import uuid
5+
import json
56
from contextlib import suppress
67
from datetime import timedelta
78
from urllib.parse import parse_qsl, urlparse
@@ -14,16 +15,17 @@
1415
from django.urls import reverse
1516
from django.utils import timezone
1617
from django.utils.translation import gettext_lazy as _
17-
from jwcrypto import jwk
18+
from jwcrypto import jwk, jwt
1819
from jwcrypto.common import base64url_encode
1920
from oauthlib.oauth2.rfc6749 import errors
21+
import requests
2022

2123
from .generators import generate_client_id, generate_client_secret
2224
from .scopes import get_scopes_backend
2325
from .settings import oauth2_settings
2426
from .utils import jwk_from_pem
2527
from .validators import AllowedURIValidator
26-
28+
from .exceptions import BackchannelLogoutRequestError
2729

2830
logger = logging.getLogger(__name__)
2931

@@ -76,6 +78,9 @@ class AbstractApplication(models.Model):
7678
* :attr:`client_secret` Confidential secret issued to the client during
7779
the registration process as described in :rfc:`2.2`
7880
* :attr:`name` Friendly name for the Application
81+
* :attr:`backchannel_logout_uri` Backchannel Logout URI (OIDC-only)
82+
* :attr:`backchannel_logout_session_required` Whether application requires
83+
session id claim in sent in token (OIDC-only)
7984
"""
8085

8186
CLIENT_CONFIDENTIAL = "confidential"
@@ -147,6 +152,12 @@ class AbstractApplication(models.Model):
147152
help_text=_("Allowed origins list to enable CORS, space separated"),
148153
default="",
149154
)
155+
backchannel_logout_uri = models.URLField(
156+
blank=True, null=True, help_text="Backchannel Logout URI where logout tokens will be sent"
157+
)
158+
backchannel_logout_session_required = models.BooleanField(
159+
default=False, help_text=_("Include SID claim in logout token?")
160+
)
150161

151162
class Meta:
152163
abstract = True
@@ -650,6 +661,75 @@ class Meta(AbstractIDToken.Meta):
650661
swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL"
651662

652663

664+
class AbstractLogoutToken(models.Model):
665+
TTL = timedelta(minutes=10)
666+
667+
id = models.BigAutoField(primary_key=True)
668+
user = models.ForeignKey(
669+
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s"
670+
)
671+
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)
672+
id_token = models.OneToOneField(oauth2_settings.ID_TOKEN_MODEL, null=True, on_delete=models.SET_NULL)
673+
created = models.DateTimeField(auto_now_add=True)
674+
675+
@property
676+
def expires(self):
677+
return self.created + self.TTL
678+
679+
@property
680+
def jwt_claim_dictionary(self):
681+
claims = {
682+
"iss": oauth2_settings.OIDC_ISS_ENDPOINT,
683+
"sub": str(self.user.id),
684+
"aud": str(self.application.client_id),
685+
"iat": int(self.created.timestamp()),
686+
"exp": int(self.expires.timestamp()),
687+
"jti": str(self.jti),
688+
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
689+
}
690+
691+
if session_key:
692+
claims["sid"] = session_key
693+
694+
return claims
695+
696+
def get_serialized_jwt(self):
697+
# Standard JWT header
698+
header = {"typ": "logout+jwt", "alg": self.application.algorithm}
699+
# RS256 consumers expect a kid in the header for verifying the token
700+
if self.application.algorithm == AbstractApplication.RS256_ALGORITHM:
701+
header["kid"] = self.application.jwk_key.thumbprint()
702+
703+
token = jwt.JWT(
704+
header=json.dumps(header, default=str),
705+
claims=json.dumps(self.jwt_claim_dictionary, default=str),
706+
)
707+
token.make_signed_token(self.application.jwk_key)
708+
return token.serialize()
709+
710+
def send_backchannel_logout_request(self):
711+
if not oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED:
712+
return
713+
try:
714+
if self.application.backchannel_logout_session_required:
715+
assert bool(self.session_key), "No Session ID provided"
716+
717+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
718+
data = {"logout_token": self.get_serialized_jwt()}
719+
response = requests.post(self.application.backchannel_logout_uri, headers=headers, data=data)
720+
response.raise_for_status()
721+
except (AssertionError, requests.RequestException) as exc:
722+
raise BackchannelLogoutRequestError(str(exc))
723+
724+
class Meta:
725+
abstract = True
726+
727+
728+
class LogoutToken(AbstractLogoutToken):
729+
class Meta(AbstractLogoutToken.Meta):
730+
swappable = "OAUTH2_PROVIDER_LOGOUT_TOKEN_MODEL"
731+
732+
653733
def get_application_model():
654734
"""Return the Application model that is active in this project."""
655735
return apps.get_model(oauth2_settings.APPLICATION_MODEL)
@@ -670,6 +750,11 @@ def get_id_token_model():
670750
return apps.get_model(oauth2_settings.ID_TOKEN_MODEL)
671751

672752

753+
def get_logout_token_model():
754+
"""Return the IDToken model that is active in this project."""
755+
return apps.get_model(oauth2_settings.LOGOUT_TOKEN_MODEL)
756+
757+
673758
def get_refresh_token_model():
674759
"""Return the RefreshToken model that is active in this project."""
675760
return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL)
@@ -699,6 +784,11 @@ def get_id_token_admin_class():
699784
return id_token_admin_class
700785

701786

787+
def get_logout_token_admin_class():
788+
"""Return the LogoutToken admin class that is active in this project."""
789+
return oauth2_settings.LOGOUT_TOKEN_ADMIN_CLASS
790+
791+
702792
def get_refresh_token_admin_class():
703793
"""Return the RefreshToken admin class that is active in this project."""
704794
refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS
@@ -729,6 +819,7 @@ def batch_delete(queryset, query):
729819
access_token_model = get_access_token_model()
730820
refresh_token_model = get_refresh_token_model()
731821
id_token_model = get_id_token_model()
822+
logout_token_model = get_logout_token_model()
732823
grant_model = get_grant_model()
733824
REFRESH_TOKEN_EXPIRE_SECONDS = oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS
734825

@@ -756,6 +847,11 @@ def batch_delete(queryset, query):
756847
else:
757848
logger.info("refresh_expire_at is %s. No refresh tokens deleted.", refresh_expire_at)
758849

850+
logout_token_query = models.Q(created__lt=now - logout_token_model.TTL)
851+
logout_tokens = logout_token_model.objects.filter(logout_token_query)
852+
logout_tokens_delete_no = batch_delete(logout_tokens, logout_token_query)
853+
logger.info("%s Expired logout tokens deleted", logout_tokens_delete_no)
854+
759855
access_token_query = models.Q(refresh_token__isnull=True, expires__lt=now)
760856
access_tokens = access_token_model.objects.filter(access_token_query)
761857

oauth2_provider/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application")
3131
ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken")
3232
ID_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken")
33+
LOGOUT_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_LOGOUT_TOKEN_MODEL", "oauth2_provider.LogoutToken")
3334
GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant")
3435
REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken")
3536

@@ -61,12 +62,14 @@
6162
"APPLICATION_MODEL": APPLICATION_MODEL,
6263
"ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL,
6364
"ID_TOKEN_MODEL": ID_TOKEN_MODEL,
65+
"LOGOUT_TOKEN_MODEL": LOGOUT_TOKEN_MODEL,
6466
"GRANT_MODEL": GRANT_MODEL,
6567
"REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL,
6668
"APPLICATION_ADMIN_CLASS": "oauth2_provider.admin.ApplicationAdmin",
6769
"ACCESS_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.AccessTokenAdmin",
6870
"GRANT_ADMIN_CLASS": "oauth2_provider.admin.GrantAdmin",
6971
"ID_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.IDTokenAdmin",
72+
"LOGOUT_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.LogoutTokenAdmin",
7073
"REFRESH_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.RefreshTokenAdmin",
7174
"REQUEST_APPROVAL_PROMPT": "force",
7275
"ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"],
@@ -92,6 +95,8 @@
9295
"client_secret_post",
9396
"client_secret_basic",
9497
],
98+
"OIDC_BACKCHANNEL_LOGOUT_ENABLED": False,
99+
"OIDC_BACKCHANNEL_LOGOUT_SESSION_ENABLED": False,
95100
"OIDC_RP_INITIATED_LOGOUT_ENABLED": False,
96101
"OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
97102
"OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS": False,

oauth2_provider/templates/oauth2_provider/application_detail.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ <h3 class="block-center-heading">{{ application.name }}</h3>
4545
<p><b>{% trans "Allowed Origins" %}</b></p>
4646
<textarea class="input-block-level" readonly>{{ application.allowed_origins }}</textarea>
4747
</li>
48+
49+
<li>
50+
<p><b>{% trans "Backchannel Logout URI" %}</b></p>
51+
<input class="input-block-level" type="text" value="{{ application.backchannel_logout_uri }}" readonly>
52+
</li>
53+
54+
<li>
55+
<p><b>{% trans "Backchannel Logout Session Required" %}</b></p>
56+
<p>{{ application.backchannel_logout_session_required|yesno:_("yes,no") }}</p>
57+
</li>
58+
4859
</ul>
4960

5061
<div class="btn-toolbar">

0 commit comments

Comments
 (0)