22import logging
33import time
44import uuid
5+ import json
56from contextlib import suppress
67from datetime import timedelta
78from urllib .parse import parse_qsl , urlparse
1415from django .urls import reverse
1516from django .utils import timezone
1617from django .utils .translation import gettext_lazy as _
17- from jwcrypto import jwk
18+ from jwcrypto import jwk , jwt
1819from jwcrypto .common import base64url_encode
1920from oauthlib .oauth2 .rfc6749 import errors
21+ import requests
2022
2123from .generators import generate_client_id , generate_client_secret
2224from .scopes import get_scopes_backend
2325from .settings import oauth2_settings
2426from .utils import jwk_from_pem
2527from .validators import AllowedURIValidator
26-
28+ from . exceptions import BackchannelLogoutRequestError
2729
2830logger = 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+
653733def 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+
673758def 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+
702792def 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
0 commit comments