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:
Settings
URLs + Views
Descope.py
Models
Authentication
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 the
django_descope
app project settings.The
WEB_COMPONENT_SRC
variable gets the attribute from our global settings file. The purpose of theWEB_COMPONENT_SRC
is to allow for the functionality of Descope's HTML login widget.PROJECT_ID
works similarly. We get theDESCOPE_PROJECT_ID
from the settings file.IS_STAFF_ROLE
andIS_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:
The
StoreJwt
class view is wrapped in Django’snever_cache
method decorator. Thenever_cache
decorator tells Django to never cache the route, meaning we do not store / cache the JWT in the browser. Nothing is saved.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.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?
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.flow_id: The
flow_id
is unique to Descope and determines the authentication flow. Ex: sign-up, sign-up-or-in, etc.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 theCONTEXT_KEY
andWEB_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. Theget_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 newFormData
object. TheFormData
object is like an HTML form but in JavaScript. It allows us to append key-value pair values so that when we fetch from thestore_jwt_url
, we can pass in theFormData
object in the body. The session and refresh JWTs along with the CSRF token are sent in theFormData
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 theinit
inherits the properties of theBaseBackend
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 theget_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 ourDescopeAuthentication
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!
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.