2
2
import logging
3
3
import time
4
4
import uuid
5
+ import json
5
6
from contextlib import suppress
6
7
from datetime import timedelta
7
8
from urllib .parse import parse_qsl , urlparse
14
15
from django .urls import reverse
15
16
from django .utils import timezone
16
17
from django .utils .translation import gettext_lazy as _
17
- from jwcrypto import jwk
18
+ from jwcrypto import jwk , jwt
18
19
from jwcrypto .common import base64url_encode
19
20
from oauthlib .oauth2 .rfc6749 import errors
21
+ import requests
20
22
21
23
from .generators import generate_client_id , generate_client_secret
22
24
from .scopes import get_scopes_backend
23
25
from .settings import oauth2_settings
24
26
from .utils import jwk_from_pem
25
27
from .validators import AllowedURIValidator
26
-
28
+ from . exceptions import BackchannelLogoutRequestError
27
29
28
30
logger = logging .getLogger (__name__ )
29
31
@@ -76,6 +78,9 @@ class AbstractApplication(models.Model):
76
78
* :attr:`client_secret` Confidential secret issued to the client during
77
79
the registration process as described in :rfc:`2.2`
78
80
* :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)
79
84
"""
80
85
81
86
CLIENT_CONFIDENTIAL = "confidential"
@@ -147,6 +152,12 @@ class AbstractApplication(models.Model):
147
152
help_text = _ ("Allowed origins list to enable CORS, space separated" ),
148
153
default = "" ,
149
154
)
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
+ )
150
161
151
162
class Meta :
152
163
abstract = True
@@ -650,6 +661,75 @@ class Meta(AbstractIDToken.Meta):
650
661
swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL"
651
662
652
663
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
+
653
733
def get_application_model ():
654
734
"""Return the Application model that is active in this project."""
655
735
return apps .get_model (oauth2_settings .APPLICATION_MODEL )
@@ -670,6 +750,11 @@ def get_id_token_model():
670
750
return apps .get_model (oauth2_settings .ID_TOKEN_MODEL )
671
751
672
752
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
+
673
758
def get_refresh_token_model ():
674
759
"""Return the RefreshToken model that is active in this project."""
675
760
return apps .get_model (oauth2_settings .REFRESH_TOKEN_MODEL )
@@ -699,6 +784,11 @@ def get_id_token_admin_class():
699
784
return id_token_admin_class
700
785
701
786
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
+
702
792
def get_refresh_token_admin_class ():
703
793
"""Return the RefreshToken admin class that is active in this project."""
704
794
refresh_token_admin_class = oauth2_settings .REFRESH_TOKEN_ADMIN_CLASS
@@ -729,6 +819,7 @@ def batch_delete(queryset, query):
729
819
access_token_model = get_access_token_model ()
730
820
refresh_token_model = get_refresh_token_model ()
731
821
id_token_model = get_id_token_model ()
822
+ logout_token_model = get_logout_token_model ()
732
823
grant_model = get_grant_model ()
733
824
REFRESH_TOKEN_EXPIRE_SECONDS = oauth2_settings .REFRESH_TOKEN_EXPIRE_SECONDS
734
825
@@ -756,6 +847,11 @@ def batch_delete(queryset, query):
756
847
else :
757
848
logger .info ("refresh_expire_at is %s. No refresh tokens deleted." , refresh_expire_at )
758
849
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
+
759
855
access_token_query = models .Q (refresh_token__isnull = True , expires__lt = now )
760
856
access_tokens = access_token_model .objects .filter (access_token_query )
761
857
0 commit comments