|
1 | 1 | import hashlib
|
| 2 | +import json |
2 | 3 | import logging
|
3 | 4 | import time
|
4 | 5 | import uuid
|
5 | 6 | from contextlib import suppress
|
6 | 7 | from datetime import timedelta
|
7 | 8 | from urllib.parse import parse_qsl, urlparse
|
8 | 9 |
|
| 10 | +import requests |
9 | 11 | from django.apps import apps
|
10 | 12 | from django.conf import settings
|
11 | 13 | from django.contrib.auth.hashers import identify_hasher, make_password
|
|
14 | 16 | from django.urls import reverse
|
15 | 17 | from django.utils import timezone
|
16 | 18 | from django.utils.translation import gettext_lazy as _
|
17 |
| -from jwcrypto import jwk |
| 19 | +from jwcrypto import jwk, jwt |
18 | 20 | from jwcrypto.common import base64url_encode
|
19 | 21 | from oauthlib.oauth2.rfc6749 import errors
|
20 | 22 |
|
| 23 | +from .exceptions import BackchannelLogoutRequestError |
21 | 24 | from .generators import generate_client_id, generate_client_secret
|
22 | 25 | from .scopes import get_scopes_backend
|
23 | 26 | from .settings import oauth2_settings
|
@@ -76,6 +79,7 @@ class AbstractApplication(models.Model):
|
76 | 79 | * :attr:`client_secret` Confidential secret issued to the client during
|
77 | 80 | the registration process as described in :rfc:`2.2`
|
78 | 81 | * :attr:`name` Friendly name for the Application
|
| 82 | + * :attr:`backchannel_logout_uri` Backchannel Logout URI (OIDC-only) |
79 | 83 | """
|
80 | 84 |
|
81 | 85 | CLIENT_CONFIDENTIAL = "confidential"
|
@@ -147,6 +151,9 @@ class AbstractApplication(models.Model):
|
147 | 151 | help_text=_("Allowed origins list to enable CORS, space separated"),
|
148 | 152 | default="",
|
149 | 153 | )
|
| 154 | + backchannel_logout_uri = models.URLField( |
| 155 | + blank=True, null=True, help_text="Backchannel Logout URI where logout tokens will be sent" |
| 156 | + ) |
150 | 157 |
|
151 | 158 | class Meta:
|
152 | 159 | abstract = True
|
@@ -629,6 +636,53 @@ def revoke(self):
|
629 | 636 | """
|
630 | 637 | self.delete()
|
631 | 638 |
|
| 639 | + def send_backchannel_logout_request(self, ttl=timedelta(minutes=10)): |
| 640 | + """ |
| 641 | + Send a logout token to the applications backchannel logout uri |
| 642 | + """ |
| 643 | + try: |
| 644 | + assert oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED, "Backchannel logout not enabled" |
| 645 | + assert self.application.algorithm != AbstractApplication.NO_ALGORITHM, ( |
| 646 | + "Application must provide signing algorithm" |
| 647 | + ) |
| 648 | + assert self.application.backchannel_logout_uri is not None, ( |
| 649 | + "URL for backchannel logout not provided by client" |
| 650 | + ) |
| 651 | + |
| 652 | + issued_at = timezone.now() |
| 653 | + expiration_date = issued_at + ttl |
| 654 | + |
| 655 | + claims = { |
| 656 | + "iss": oauth2_settings.OIDC_ISS_ENDPOINT, |
| 657 | + "sub": str(self.user.id), |
| 658 | + "aud": str(self.application.client_id), |
| 659 | + "iat": int(issued_at.timestamp()), |
| 660 | + "exp": int(expiration_date.timestamp()), |
| 661 | + "jti": self.jti, |
| 662 | + "events": {"http://schemas.openid.net/event/backchannel-logout": {}}, |
| 663 | + } |
| 664 | + |
| 665 | + # Standard JWT header |
| 666 | + header = {"typ": "logout+jwt", "alg": self.application.algorithm} |
| 667 | + |
| 668 | + # RS256 consumers expect a kid in the header for verifying the token |
| 669 | + if self.application.algorithm == AbstractApplication.RS256_ALGORITHM: |
| 670 | + header["kid"] = self.application.jwk_key.thumbprint() |
| 671 | + |
| 672 | + token = jwt.JWT( |
| 673 | + header=json.dumps(header, default=str), |
| 674 | + claims=json.dumps(claims, default=str), |
| 675 | + ) |
| 676 | + |
| 677 | + token.make_signed_token(self.application.jwk_key) |
| 678 | + |
| 679 | + headers = {"Content-Type": "application/x-www-form-urlencoded"} |
| 680 | + data = {"logout_token": token.serialize()} |
| 681 | + response = requests.post(self.application.backchannel_logout_uri, headers=headers, data=data) |
| 682 | + response.raise_for_status() |
| 683 | + except (AssertionError, requests.RequestException) as exc: |
| 684 | + raise BackchannelLogoutRequestError(str(exc)) |
| 685 | + |
632 | 686 | @property
|
633 | 687 | def scopes(self):
|
634 | 688 | """
|
@@ -859,3 +913,15 @@ def is_origin_allowed(origin, allowed_origins):
|
859 | 913 | return True
|
860 | 914 |
|
861 | 915 | return False
|
| 916 | + |
| 917 | + |
| 918 | +def send_backchannel_logout_requests(user): |
| 919 | + """ |
| 920 | + Creates logout tokens for all id tokens associated with the user |
| 921 | + """ |
| 922 | + id_tokens = IDToken.objects.filter(application__backchannel_logout_uri__isnull=False, user=user) |
| 923 | + for id_token in id_tokens: |
| 924 | + try: |
| 925 | + id_token.send_backchannel_logout_request() |
| 926 | + except BackchannelLogoutRequestError as exc: |
| 927 | + logger.warn(str(exc)) |
0 commit comments