From 81b6794e8b1692f7069f14e4275f63bbbe88ffae Mon Sep 17 00:00:00 2001
From: Omer Cohen <omer@descope.com>
Date: Tue, 13 Jun 2023 11:37:07 -0700
Subject: [PATCH] feat: expose descope sdk (#153)

## Related Issues

Fixes https://github.com/descope/django-descope/issues/152

## Description

* Add an optional setting to allow using [Descope SDK Management
API](https://docs.descope.com/manage/)
* Expose a reusable `descope` client on top level of module
* Fix role validation to use Descope SDK
---
 README.md                                | 10 ++++++-
 django_descope/__init__.py               |  7 +++++
 django_descope/authentication.py         | 34 ++++++++++++------------
 django_descope/models.py                 | 33 +++++++++++------------
 django_descope/settings.py               |  1 +
 django_descope/views.py                  |  6 +----
 example_app/templates/descope_login.html |  1 +
 example_app/urls.py                      |  4 +--
 example_app/views.py                     | 22 +++++++++++++--
 9 files changed, 73 insertions(+), 45 deletions(-)

diff --git a/README.md b/README.md
index 190e186..36e264f 100644
--- a/README.md
+++ b/README.md
@@ -68,8 +68,16 @@ pip install django-descope
 
 The following settings are available to configure in your project `settings.py`
 
+#### Required
+
+```
+DESCOPE_PROJECT_ID
+```
+
+#### Optional
+
 ```
-DESCOPE_PROJECT_ID **Required**
+DESCOPE_MANAGEMENT_KEY
 DESCOPE_IS_STAFF_ROLE
 DESCOPE_IS_SUPERUSER_ROLE
 ```
diff --git a/django_descope/__init__.py b/django_descope/__init__.py
index e69de29..4744d1d 100644
--- a/django_descope/__init__.py
+++ b/django_descope/__init__.py
@@ -0,0 +1,7 @@
+from descope import DescopeClient
+
+from .settings import MANAGEMENT_KEY, PROJECT_ID
+
+descope_client = DescopeClient(project_id=PROJECT_ID, management_key=MANAGEMENT_KEY)
+
+all = [descope_client]
diff --git a/django_descope/authentication.py b/django_descope/authentication.py
index 88ac04e..11831bf 100644
--- a/django_descope/authentication.py
+++ b/django_descope/authentication.py
@@ -1,34 +1,32 @@
 import logging
 
-from descope import REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, DescopeClient
+from descope import REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SESSION_TOKEN_NAME
 from descope.exceptions import AuthException
+from django.conf import settings
 from django.contrib.auth import logout
 from django.contrib.auth.backends import BaseBackend
 from django.http import HttpRequest
 
+from . import descope_client
 from .models import DescopeUser
-from .settings import PROJECT_ID
 
 logger = logging.getLogger(__name__)
 
 
 class DescopeAuthentication(BaseBackend):
-    _dclient = DescopeClient(PROJECT_ID)
-
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
     def authenticate(self, request: HttpRequest):
-        session = request.session.get(SESSION_COOKIE_NAME)
-        refresh = request.session.get(REFRESH_SESSION_COOKIE_NAME)
+        session_token = request.session.get(SESSION_COOKIE_NAME)
+        refresh_token = request.session.get(REFRESH_SESSION_COOKIE_NAME)
 
         logger.debug("Validating (and refreshing) Descope session")
-        logger.debug("session %s", session)
-        logger.debug("refresh %s", refresh)
         try:
-            validated_token = self._dclient.validate_and_refresh_session(
-                session, refresh
+            validated_session = descope_client.validate_and_refresh_session(
+                session_token, refresh_token
             )
+
         except AuthException as e:
             """
             Ask forgiveness, not permission.
@@ -41,14 +39,16 @@ def authenticate(self, request: HttpRequest):
             logout(request)
             return None
 
-        logger.debug(validated_token)
-        return self.get_user(request, validated_token, refresh)
+        if settings.DEBUG:
+            # Contains sensitive information, so only log in DEBUG mode
+            logger.debug(validated_session)
+        return self.get_user(request, validated_session, refresh_token)
 
-    def get_user(self, request: HttpRequest, validated_token=None, refresh_token=None):
-        if validated_token:
-            username = validated_token.get("userId") or validated_token.get("sub")
+    def get_user(self, request: HttpRequest, validated_session, refresh_token):
+        if validated_session:
+            username = validated_session[SESSION_TOKEN_NAME]["sub"]
             user, created = DescopeUser.objects.get_or_create(username=username)
-            user.sync(validated_token, refresh_token)
-            request.session[SESSION_COOKIE_NAME] = user.session
+            user.sync(validated_session, refresh_token)
+            request.session[SESSION_COOKIE_NAME] = user.session_token["jwt"]
             return user
         return None
diff --git a/django_descope/models.py b/django_descope/models.py
index a1c901c..0647d84 100644
--- a/django_descope/models.py
+++ b/django_descope/models.py
@@ -1,11 +1,11 @@
 import logging
 
-from descope import DescopeClient
+from descope import SESSION_TOKEN_NAME
 from django.contrib.auth import models as auth_models
 from django.core.cache import cache
-from django.utils.functional import cached_property
 
-from .settings import IS_STAFF_ROLE, IS_SUPERUSER_ROLE, PROJECT_ID
+from . import descope_client
+from .settings import IS_STAFF_ROLE, IS_SUPERUSER_ROLE
 
 logger = logging.getLogger(__name__)
 
@@ -14,35 +14,32 @@ class DescopeUser(auth_models.User):
     class Meta:
         proxy = True
 
-    # User is always active since Descioe will never issue a token for an
+    # User is always active since Descope will never issue a token for an
     # inactive user
     is_active = True
-    _descope = DescopeClient(PROJECT_ID)
 
     def sync(self, session, refresh):
-        self.token = session
-        self.session = session.get("jwt")
-        self.refresh = refresh
-        self.user = session.get("user")
-        self.firstSeen = session.get("firstSeen")
+        self.session_token = session[SESSION_TOKEN_NAME]  # this should always exist
+        self.refresh_token = refresh
         self.username = self._me.get("userId")
+        self.user = self.username
         self.email = self._me.get("email")
-        self.is_staff = IS_STAFF_ROLE in self._roles
-        self.is_superuser = IS_SUPERUSER_ROLE in self._roles
+        self.is_staff = descope_client.validate_roles(
+            self.session_token, [IS_STAFF_ROLE]
+        )
+        self.is_superuser = descope_client.validate_roles(
+            self.session_token, [IS_SUPERUSER_ROLE]
+        )
         self.save()
 
     def __str__(self):
         return f"DescopeUser {self.username}"
 
-    @cached_property
+    @property
     def _me(self):
         return cache.get_or_set(
-            f"descope_me:{self.username}", lambda: self._descope.me(self.refresh)
+            f"descope_me:{self.username}", lambda: descope_client.me(self.refresh_token)
         )
 
-    @cached_property
-    def _roles(self):
-        return self.token.get("roles", [])
-
     def get_username(self):
         return self.username
diff --git a/django_descope/settings.py b/django_descope/settings.py
index c2353f6..a24c6ad 100644
--- a/django_descope/settings.py
+++ b/django_descope/settings.py
@@ -5,6 +5,7 @@
     settings, "DESCOPE_WEB_COMPONENT_SRC", "https://unpkg.com/@descope/web-component"
 )
 
+MANAGEMENT_KEY = getattr(settings, "DESCOPE_MANAGEMENT_KEY", None)
 PROJECT_ID = getattr(settings, "DESCOPE_PROJECT_ID", None)
 if not PROJECT_ID:
     raise ImproperlyConfigured('"DESCOPE_PROJECT_ID" is required!')
diff --git a/django_descope/views.py b/django_descope/views.py
index cf65bb5..748f760 100644
--- a/django_descope/views.py
+++ b/django_descope/views.py
@@ -1,18 +1,14 @@
 import logging
 
-from descope import REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, DescopeClient
+from descope import REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME
 from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
 from django.utils.decorators import method_decorator
 from django.views import View
 from django.views.decorators.cache import never_cache
 
-from . import settings
-
 # User = get_user_model()
 logger = logging.getLogger(__name__)
 
-descope_client = DescopeClient(project_id=settings.PROJECT_ID)
-
 
 @method_decorator([never_cache], name="dispatch")
 class StoreJwt(View):
diff --git a/example_app/templates/descope_login.html b/example_app/templates/descope_login.html
index 7ff0b47..2ce29cf 100644
--- a/example_app/templates/descope_login.html
+++ b/example_app/templates/descope_login.html
@@ -12,6 +12,7 @@
   <body>
     {% if user.is_authenticated %}
     <h1>Welcome {{ user.email }} you are logged in!</h1>
+    <p><a href="{% url 'debug' %}">Detailed user information</a></p>
     <p><a href="{% url 'logout' %}">Log Out</a></p>
     {% else %}
       {% descope_flow "sign-up-or-in" "/" %}
diff --git a/example_app/urls.py b/example_app/urls.py
index 52225bf..848b22a 100644
--- a/example_app/urls.py
+++ b/example_app/urls.py
@@ -1,9 +1,9 @@
 from django.urls import path
 from django.views.generic import TemplateView
 
-from .views import Index
+from .views import Debug
 
 urlpatterns = [
     path("", TemplateView.as_view(template_name="descope_login.html"), name="index"),
-    path("test", Index.as_view(), name="test"),
+    path("debug", Debug.as_view(), name="debug"),
 ]
diff --git a/example_app/views.py b/example_app/views.py
index 8f54022..30f4e3b 100644
--- a/example_app/views.py
+++ b/example_app/views.py
@@ -5,6 +5,9 @@
 from django.urls import reverse
 from django.views import View
 
+from django_descope import descope_client
+from django_descope.models import DescopeUser
+
 logger = logging.getLogger(__name__)
 
 
@@ -14,9 +17,16 @@ def get(self, request: HttpRequest):
         return HttpResponseRedirect(reverse("index"))
 
 
-class Index(View):
+class Debug(View):
     def get(self, request: HttpRequest):
-        logger.info("Index view called")
+        logger.info("Debug view called")
+        mgmt = False
+        try:
+            descope_client.mgmt
+            mgmt = True
+        except Exception:
+            pass
+
         return JsonResponse(
             {
                 "user": request.user.username,
@@ -24,5 +34,13 @@ def get(self, request: HttpRequest):
                 "is_staff": request.user.is_staff,
                 "is_superuser": request.user.is_superuser,
                 "email": request.user.email,
+                "session": request.user.session_token,
+                "is_mgmt_available": mgmt,
+            }
+            if isinstance(request.user, DescopeUser)
+            else {
+                "is_authenticated": request.user.is_authenticated,
+                "is_anonymous": request.user.is_anonymous,
+                "is_active": request.user.is_active,
             }
         )