back arrowBack to Blog

Developers

A Deep Dive into the Descope Django Plugin

Django SDK blog thumbnail (1)

In this tutorial, we will cover in detail how to integrate Descope authentication into your app using the Django Plugin.

To see the django-descope plugin in action, check out this tutorial where we create a sample text summarizer app with full authentication and RBAC.

Prerequisites

All the code for the tutorial will be in the GitHub Repository at django-descope. Instructions on the installation are on the README.md file. 

Overview

To break down the django-descope plugin, we will go over six parts:

  1. Settings

  2. URLs + Views

  3. Descope.py 

  4. Models

  5. Authentication

  6. Middleware

Settings

Upon cloning the repository, you will see django_descope, example_app, and static folder. Django_descope is our main plugin, while example_app is the app that was created to demonstrate the functionality of the django_descope plugin. 

Let’s first view our settings.py file.

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured

WEB_COMPONENT_SRC = getattr(
    settings, "DESCOPE_WEB_COMPONENT_SRC", "https://unpkg.com/@descope/web-component"
)

PROJECT_ID = getattr(settings, "DESCOPE_PROJECT_ID", None)
if not PROJECT_ID:
    raise ImproperlyConfigured('"DESCOPE_PROJECT_ID" is required!')

# Role names to create in Descope that will map to User attributes
IS_STAFF_ROLE = getattr(settings, "DESCOPE_IS_STAFF_ROLE", "is_staff")
IS_SUPERUSER_ROLE = getattr(settings, "DESCOPE_IS_SUPERUSER_ROLE", "is_superuser")

Code block: settings.py

  • First, we import our global settings into django_descope’s app project settings.

  • The WEB_COMPONENT_SRC variable gets the attribute from our global settings file. The purpose of the WEB_COMPONENT_SRC is to allow for the functionality of Descope's HTML login widget. 

  • PROJECT_ID works similarly. We get the DESCOPE_PROJECT_ID from the settings file. 

  • IS_STAFF_ROLE and IS_SUPERUSER_ROLE also look for the attributes in the settings folder and set the default values if they’re not found.

Before we see our routes and paths, let’s see our init.py file.

URLs + Views

Now let’s make our way to the urls.py file. There's only one path, store_jwt, which calls the StoreJwt view in our views.py file.

import logging

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

# User = get_user_model()
logger = logging.getLogger(__name__)


@method_decorator([never_cache], name="dispatch")
class StoreJwt(View):
    def post(self, request: HttpRequest):
        session = request.POST.get(SESSION_COOKIE_NAME)
        refresh = request.COOKIES.get(REFRESH_SESSION_COOKIE_NAME)
        if not refresh:
            refresh = request.POST.get(REFRESH_SESSION_COOKIE_NAME)

        if session and refresh:
            request.session[SESSION_COOKIE_NAME] = session
            request.session[REFRESH_SESSION_COOKIE_NAME] = refresh
            return JsonResponse({"success": True})

        return HttpResponseBadRequest()

Code block: views.py file

Starting at the top, we import our Descope dependencies using the Descope Python SDK. Here’s what happens next:

  1. The StoreJwt class view is wrapped in Django’s never_cache method decorator. The never_cache decorator tells Django to never cache the route, meaning we do not store / cache the JWT in the browser. Nothing is saved. 

  2. The post method within our StoreJwt class handles the post requests. In the post method, we get our session and refresh tokens from the cookie in the request. Every time a request is made, cookies are sent in the request. The if-statement checks if a refresh cookie exists, else we get it from the POST request. 

  3. The last if statement now sets our Django session to the cookie values. The session persists across requests, so the cookie values must match the session values. Every time a request is made, Django’s session middleware gets the incoming request before it reaches our routes in the urls.py, grabs our cookies in the request, and sets the proper session values.  

So, where is StoreJwt called?

Descope.py

Within the django_descope plugin, open the templatetags folder and open descope.py. The custom template tag renders the Descope authentication component.

The folder is named templatetags because Django recognizes this directory as the place to store the template tags.

import os

from descope import REFRESH_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME
from django import template
from django.middleware.csrf import get_token as csrf_token
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe

from ..settings import PROJECT_ID, WEB_COMPONENT_SRC

Code block: descope_flow imports

At the top, we import our PROJECT_ID and WEB_COMPONENT_SRC variables from our settings file. Also notice that we import template from Django.

NOTE: the WEB_COMPONENT_SRC gives us access to the functionality from Descope’s web.js package

Now let’s see how we initialize our template tags.

register = template.Library()
CONTEXT_KEY = "descope_wc_included"


@register.simple_tag(takes_context=True)

Code block: descope_flow decorator

An instance of the template Library is created to register our module. Then we have our descope_flow function/template that’s wrapped in a simple_tag decorator. This tells Django that the descope_flow is our custom template tag and allows us to accept any number of parameters. 

Notice how within the simple_tags there is take_context=True. This allows Django to get the current context data of the HTML template. 

Now let’s look at the descope_flow method and its parameters.

def descope_flow(context, flow_id, success_redirect):

Code block: descope_flow parameters

Let’s take a deeper look at our parameters. 

The parameters are context, flow_id, and success_redirect. These three arguments must be present in the descope_flow template tags when we use them in the HTML templates. So what are these?

  1. context: Since we set the take_context to true, we must have the context as the first parameter. We can now access the data of the template. 

  2. flow_id: the flow_id is unique to Descope and determines the authentication flow. Ex: sign-up, sign-up-or-in, etc. 

  3. success_redirect: This is the URL we will be redirected to upon successful login.

Now let’s look at the rest of the code. 

  script = ""
    if not context.get(CONTEXT_KEY):
        script += f'<script src="{WEB_COMPONENT_SRC}"></script>'
        context[CONTEXT_KEY] = True
    id = "descope-" + get_random_string(length=4)
    store_jwt_url = reverse("django_descope:store_jwt")
    flow = f"""
    <descope-wc id="{id}" project-id="{PROJECT_ID}" flow-id="{flow_id}"
        base-url="{os.environ.get('DESCOPE_BASE_URI', '')}"></descope-wc>
    <script>
        const descopeWcEle = document.getElementById('{id}');
        descopeWcEle.addEventListener('success', async (e) => {{
            const formData = new FormData();
            formData.append('{SESSION_COOKIE_NAME}', e.detail.sessionJwt);
            formData.append('{REFRESH_SESSION_COOKIE_NAME}', e.detail.refreshJwt);
            formData.append('csrfmiddlewaretoken','{csrf_token(context.request)}')

            await fetch("{store_jwt_url}", {{
                method: "POST",
                body: formData,
            }})
            document.location.replace("{success_redirect}")
        }});
    </script>
    """
    return mark_safe(script + flow)

Code block: descope_flow template

Here’s the breakdown:

  • We have a script variable which is set to an empty string for now. There are also the CONTEXT_KEY and WEB_COMPONENT_SRC variables. 

  • We need an ID for our descope component so we have an id variable where we call the get_random_string method that is inbuilt and imported from Django. The get_random_string method generates a random alphanumeric string. 

  • The store_jwt_url variable stores our route which is in our urls.py paths. 

  • The flow string variable stores the actual Descope JavaScript widget that will be rendered in the HTML template. It contains our ID, Project ID, and Flow ID.

  • The form_data variable creates a new FormData object. The FormData object is like an HTML form but in JavaScript. It allows us to append key-value pair values so that when we fetch from the store_jwt_url, we can pass in the FormData object in the body. The session and refresh JWTs along with the CSRF token are sent in the FormData object. 

  • We then return the entire string wrapped in mark_safe, which tells Django that it’s safe to be rendered as output in HTML. 

Now let’s see how we create and store users through the user models.  

Models

Models in Django are an important part of handling data and our authentication because it allows us to modify the data schema.

import logging

from descope import SESSION_TOKEN_NAME
from django.contrib.auth import models as auth_models
from django.core.cache import cache

from . import descope_client
from .settings import IS_STAFF_ROLE, IS_SUPERUSER_ROLE

Code block: Model imports

We import the django auth models and we also import our IS_STAFF_ROLE, IS_SUPERUSER_ROLE and PROJECT_ID from our global project settings file. 

logger = logging.getLogger(__name__)


class DescopeUser(auth_models.User):
    class Meta:
        proxy = True

    # User is always active since Descope will never issue a token for an
    # inactive user
    is_active = True

    def sync(self, session, refresh):
        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 = 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}"

    @property
    def _me(self):
        return cache.get_or_set(
            f"descope_me:{self.username}", lambda: descope_client.me(self.refresh_token)
        )

    def get_username(self):
        return self.username

Code block: models.py file

The DescopeUser model inherits the attributes from Django's default auth_models user model. The meta class within our model then allows us to add different properties to our model. Setting the proxy to true allows us to manipulate the properties of the model as well.

We then have the sync function which is the constructor of the class; it takes in the session and refresh tokens as arguments. 

In the _me method, we return cache.get_or_set where we can set key-value pairs to the cache. We have the descope_me set to our username and a lambda function that calls _descope.me with the refresh token. The _descope.me is from Descope and allows us to get user information such as name and email. 

Now let’s see where we use our DescopeUser model. 

Authentication.py

Our authentication.py file handles the validation and authentication of the user.

import logging

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

logger = logging.getLogger(__name__)


class DescopeAuthentication(BaseBackend):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def authenticate(self, request: HttpRequest):
        session_token = request.session.get(SESSION_COOKIE_NAME)
        refresh_token = request.session.get(REFRESH_SESSION_COOKIE_NAME)

        logger.debug("Validating (and refreshing) Descope session")
        try:
            validated_session = descope_client.validate_and_refresh_session(
                session_token, refresh_token
            )

        except AuthException as e:
            """
            Ask forgiveness, not permission.
                - Grace Hopper

            This exception will be thrown on every unauthenticated request to
            ensure logging out an invalid user.
            """
            logger.debug(e)
            logout(request)
            return None

        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_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_session, refresh_token)
            request.session[SESSION_COOKIE_NAME] = user.session_token["jwt"]
            return user
        return None

Code block: authentication.py file

In our authentication.py file, descope_client is imported from our init file. We also have our DescopeAuthentication Backend class. Since we’re using a custom authentication backend, the BaseBackend class is inherited, which is Django’s default way of handling permissions and user management. 

Here’s the breakdown: 

  • The super() in the init inherits the properties of the BaseBackend class. 

  • In the authenticate method, we validate the session and refresh tokens which we get from the session in the request. In the try block, we try validating the session and refresh token using the descope_client.validate_and_refresh_session method.

  • In the get_user method, we take in the validated session and refresh token as arguments and use them to get our user information. 

Now that we have the Backend and Models, let’s create a middleware.py file to add it to our entire Django app. At the very beginning of the tutorial, we added the middleware in our settings.py.

Middleware.py

The middleware ties everything together.

import logging

from django.contrib.auth import login
from django.http import HttpRequest, HttpResponse

from .authentication import DescopeAuthentication

logger = logging.getLogger(__name__)


class DescopeMiddleware:
    _auth = DescopeAuthentication()

    def __init__(self, get_response: HttpResponse = None):
        self.get_response = get_response

    def __call__(self, request: HttpRequest):
        user = self._auth.authenticate(request)
        if user:
            login(request, user)
        return self.get_response(request)

Code block: middleware.py file

Every middleware has two important methods – the init and the call. 

  • Our init must accept the get_response parameter to initialize the middleware only once when the server starts. 

  • The call method is called every time a request is made. In the call method, we use our DescopeAuthentication authenticate function to authenticate the request. 

We then check the user and call the login function that Django provides (see import statement).  

Congrats!

Whew! That was a lot to take in but hopefully you learned something new about Django authentication or Descope! 

Jim Office GIF-min

To see the Descope Django plugin in action, check out this tutorial where we build a sample app with full authentication and custom admin login.

If reading this tutorial made you curious to try Descope, sign up for a Free Forever account and join AuthTown, our open user community for developers looking to learn about authentication.