Authorizing the Notion API from Django (Part 2)

In part 2 of this tutorial, we turn the bare bones example into the basics of a real web application.

In this part of the tutorial, weā€™re going to turn the bare bones implementation from part 1 into the foundation of a real use case. Weā€™ll add a home page, basic login functionality, store the response from Notion in the DB, and use the token to call the API and display some information. Finally, weā€™ll add in the state parameter for additional security.

This post builds on Part 1, so either follow it first or grab the Part 1 code on Github.

Create a Home Page to trigger OAuth flow #

In part 1, we just created the bare minimum views to handle the OAuth flow and show that weā€™d gotten the token, but in a real application youā€™ll want a more user friendly experience. In this step, weā€™ll create a page at the root of the web application where we can kick off the OAuth flow by clicking on a link.

If your venv from part 1 isnā€™t active, make sure to activate it: .venv

scripts\activate on Windows, or source .venv/bin/activate on Mac or Linux.

Create a new Django app called notion_demo: python manage.py startapp notion_demo. This is where weā€™ll put our home page.

Create the View #

First, letā€™s create a view for the home page in notion_demo/views.py.

from django.shortcuts import render

def home(request):
return render(request, 'notion_demo/home.html')

Set up templates #

The view code tells Django to render a template. Since this is our first template, we need to tell Django where to load the templates from. For this tutorial, we will put all of our templates into a top level folder called templates

Django templates frequently extend other templates, so that you donā€™t have to duplicate all of your page structure. Weā€™ll set up a base template which weā€™ll use for all pages at templates/base.html.

{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!--IE compatibility-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--Mobile friendly-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock title %}</title>
</head>
<body>
{% block content %}
{% endblock content %}
</body>
</html>

This sets up a base template which will load static files (like images), provides some html boilerplate, and has two customizable blocks, named title and content, where weā€™ll put the page title, and the page content respectively.

We can them make a home page template that extends from this base template to fill in those blocks. Create a folder at templates/notion_demo and add a file called home.htmlwith the following:

{% extends 'base.html' %}

{% block title %}Home{% endblock %}

{% block content %}
<h1>Welcome to the Notion OAuth Tutorial!</h1>
<a href="{% url 'notion_oauth:notion_auth_start' %}">Click here to connect a Notion Workspace</a>
{% endblock content %}

This inserts content into the two blocks weā€™ve defined in base.html, and creates a link to the view to kick off the authorization, which we named notion_auth_start in the notion_oauth app when we set it up in urlpatterns in notion_oauth/urls.pyin part 1.

At this point, your folders should look like this:

Screenshot showing folder structure

In oauth_tutorial/settings.py, find the TEMPLATES variable and add 'templates' to the DIRS list (it should be empty to start in a default Django project). This tells Django to look for templates in the templates folder.

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

Install app and configure URLs #

Add notion_demo to INSTALLED_APPS in settings.py:

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'encrypted_model_fields',
'notion_oauth',
'notion_demo'
]

And finally hook it up the URLs. First off, add an app level urls.py in notion_demo:

from django.urls import path

from . import views

app_name = 'notion_demo'

urlpatterns = [
path('', views.home, name='home'),
]

Then load the app URLs in oauth_tutorial/urls.py

urlpatterns = [
path('admin/', admin.site.urls),
path('notion/', include('notion_oauth.urls')),
path('', include('notion_demo.urls'))
]

Run it to test #

At this point, you should be able to run python manage.py runserver and go to localhost:8000, and see a page with Home in the title bar (tab), and showing ā€œWelcome to the Notion OAuth Tutorial!ā€, instead of a Django error page, and clicking on the link should trigger the OAuth flow.

Screenshot of home page with link for OAuth flow

Add Login Functionality #

Django comes built in with login infrastructure, mostly you just need to configure a template. Weā€™ll add a login here so that we can associate Notion tokens with users. Another solution here might be to create a unique URL for each integration.

Create Login template #

Weā€™ll use a template based on an example from the Django documentation. This goes into templates/registration/login.html to stick with defaults. For more details, checkout the Django documentation on the user authentication system. Of course for a real app you would want to customize this and add styles! But this tutorial is long enough without trying to look pretty.

{% extends "base.html" %}

{% block content %}

{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}

{% if next %}
{% if user.is_authenticated %}
<p>Your account doesn't have access to this page. To proceed,
please login with an account that has access.</p>
{% else %}
<p>Please login to see this page.</p>
{% endif %}
{% endif %}

<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
</tr>
<tr>
<td>{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
</tr>
</table>

<input type="submit" value="login">
<input type="hidden" name="next" value="{{ next }}">
</form>

{% endblock %}

Configure login URLs #

The 'django.contrib.auth' app should be in INSTALLED_APPS in your settings.py by default.

We then need to hook up the views provided by from this app, in oauth_tutorial/urls.py. Putting it under accounts is the default and allows us to use it with no further configuration.

urlpatterns = [
path('admin/', admin.site.urls),
path('accounts/', include('django.contrib.auth.urls')),
path('notion/', include('notion_oauth.urls')),
path('', include('notion_demo.urls'))
]

Add login_required decorator to view #

To actually require login, we can use the @login_required decorator on any views where we want to require login. This adds logic before the view is called to redirect to the login page if the user isnā€™t authenticated.

from django.shortcuts import render
from django.contrib.auth.decorators import login_required

@login_required
def home(request):
return render(request, 'notion_demo/home.html')

Migrate the database #

Finally, since we havenā€™t actually used the database so far in this tutorial series, weā€™ll need to run python manage.py migrate to set up the tables that come with auth and other default Django apps.

That should output something like this, assuming it hasnā€™t been run before.

>python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying sessions.0001_initial... OK

Now, if you go to [localhost:8000](http://localhost:8000) you should be redirected to the login page, but we havenā€™t created any users yet so you canā€™t login!

Screenshot of Login Page

Create Users #

To create normal users, we can use Djangoā€™s admin interface, but first, we need to set up a superuser using manage.pyā€™s createsuperuser command. Run createsuperuser with a username and email, and you will then be prompted for a password, which must be at least 8 characters and not too similar to the username.

python manage.py createsuperuser --username=joe --email=joe@example.com

Ensure your server is running, then this user can then login using the login form (use the username, not email), or access the admin interface at http://localhost:8000/admin/, where you can create more users if desired at http://localhost:8000/admin/auth/user/add/.

Test Logging In #

At this point, we can now login, and trigger the Notion flow from a link rather than going to the URL directly, but weā€™re still not saving the token or using it anywhere.

Save Tokens by User #

Notion returns the token along with some other information about the Notion user and workspace. We need to save the token so that we can use it for API calls, and Notion suggests saving all of this information even if you donā€™t need it right now, as it can be harder to retrieve later.

Require login on notion_oauth views #

Because we want to associate the token with a Django user, we need the user to be logged in while theyā€™re doing the Notion Authorization. So weā€™ll require login for these views.

To start with, go to notion_oauth/views.py, import the login_required decorator, from django.contrib.auth.decorators import login_required, and add the @login_required decorator to the two views, notion_auth_start and notion_redirect. This will look a little something like the snippet below:

from django.shortcuts import redirect
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse

import requests

from oauthlib.oauth2 import WebApplicationClient

# ...

@login_required
def notion_auth_start(request):
#... rest of the function

@login_required
def notion_redirect(request):
# ... rest of the function

Set up Encrypted Fields #

An access token, particularly a long lived one such as returned by Notion, represents a secret piece of information similar to a password. To store them securely, we will use django-encrypted-model-fields to encrypt the field in the database. This requires installing the package and setting an encryption key in settings. so that we can use an encrypted field in our model.

To install the package, pip install django-encrypted-model-fields.

Now we need to set up a variable in settings called FIELD_ENCRYPTION_KEYwith an encryption key. To do so securely, we will load it from the environment. Add FIELD_ENCRYPTION_KEY = os.environ.get('FIELD_ENCRYPTION_KEY', '') to the bottom of oauth_tutorial/settings.py.

To generate a key, django-encrypted-model-fields suggests running this code in a Python shell.

import os
import base64

new_key = base64.urlsafe_b64encode(os.urandom(32))
print(new_key)

Note that this prints out as a bytes object, like bā€™ā€™, we just care about the value. Set the output of that script to FIELD_ENCRYPTION_KEY in your .env file like so:

FIELD_ENCRYPTION_KEY=TyQIIyj7VMctzTxDOoQaYDCOS2LUt8gyBUTJM9iKeFw=

Finally, we need to load this variable to the Django settings in oauth_tutorial/settings.py:

# At the bottom of oauth_tutorial/settings.py
FIELD_ENCRYPTION_KEY = os.environ.get("FIELD_ENCRYPTION_KEY", "")

Create Model #

Next, we will create a model so that we can store the information returned by Notion. A model is the Python representation of a database table, and creating the table will be handled by Djangoā€™s migration system. This will include the information returned by Notion, including the token, as well as our user, as defined by settings.AUTH_USER_MODEL.

Django provides a default user model with no configuration required which we will use for this tutorial; for a real application, itā€™s a good idea to create your own subclass of AbstractUser in case you want to add extra information in the future, as making changes to your user class is easier than changing it entirely.

The model for our Notion authorization data goes in notion_oauth/models.py. user is a reference to the user of our app who connected to Notion, while all other fields come from Notion. access_token uses the EncryptedCharField that we configured in the previous section. Finally, we give it a friendly representation of the workspace_name for when it gets printed.

from django.db import models
from django.conf import settings

from encrypted_model_fields.fields import EncryptedCharField

class NotionAuthorization(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
access_token = EncryptedCharField(max_length=100)
bot_id = models.CharField(max_length=100) # Maybe Primary Key
duplicated_template_id = models.CharField(max_length=100, blank=True, null=True)
workspace_name = models.CharField(max_length=500)
workspace_icon = models.URLField()
workspace_id = models.CharField(max_length=100)
owner = models.JSONField() # Don't really care about details for now but want to store

def __str__(self):
return self.workspace_name

Register model in Admin #

Next, weā€™ll register the model so we can see it in the Admin panel. Letā€™s set it up so we see the user, and the notion workspace name and ID in the Admin UI. Registering this allows us to do things like go in and delete it in the Admin UI which is handy for resetting things! Add this code to notion_oauth/admin.py.

from django.contrib import admin

from .models import NotionAuthorization

# Register your models here.
@admin.register(NotionAuthorization)
class NotionAuthorizationAdmin(admin.ModelAdmin):
list_display = ['user', 'workspace_name', 'workspace_id']

Make and run Migrations to update the database #

After defining a model, to create the table we need to use manage.py to make the migration files, and then run the migrations to apply the changes to the database.

python manage.py makemigrations
python manage.py migrate

Which should look something like this when you run it:

>python manage.py makemigrations
Migrations for 'notion_oauth':
notion_oauth\migrations\0001_initial.py
- Create model NotionAuthorization

>python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, notion_oauth, sessions
Running migrations:
Applying notion_oauth.0001_initial... OK

Save the token in the view #

We will now use the model to save the token. In notion_oauth/views.py, import the model we just defined near the top: from .models import NotionAuthorization.

Weā€™ll add the logic to handle the response after we POST to the token endpoint. We use our oauthlib client to parse the body of the response, which validates it and gives us a dictionary, and convert those fields into our NotionAuthorization object, and save it to the database. Finally, weā€™ll redirect to the home page instead of spitting out the token response.

# Update the following lines in imports:
# from django.shortcuts import redirect, reverse # (Add reverse)
# from django.http import HttpResponseRedirect # (Instead of HttpResponse)
# from .models import NotionAuthorization

# Replace the return statement of notion_redirect function with the code below
if response.ok:
token_response = client.parse_request_body_response(response.text)

authorization = NotionAuthorization.objects.create(
user = request.user,
access_token = token_response.get("access_token"),
bot_id = token_response.get("bot_id"),
duplicated_template_id = token_response.get("duplicated_template_id", None),
workspace_name = token_response.get("workspace_name"),
workspace_icon = token_response.get("workspace_icon"),
workspace_id = token_response.get("workspace_id"),
owner = token_response.get("owner")
)
authorization.save()

return HttpResponseRedirect(reverse('notion_demo:home'))

Full Code of notion_oauth/views.py #

Since thereā€™s been a number of changes to this file, hereā€™s what it should look like at this point:

from django.shortcuts import redirect, reverse
from django.conf import settings
from django.http import HttpResponseRedirect
from django.contrib.auth.decorators import login_required

import requests
from oauthlib.oauth2 import WebApplicationClient

from .models import NotionAuthorization

server_url = "http://localhost:8000" # The URL of this server
redirect_uri = f"{server_url}/notion/redirect"

authorization_base_url = 'https://api.notion.com/v1/oauth/authorize'

token_url = 'https://api.notion.com/v1/oauth/token'

@login_required
def notion_auth_start(request):
client = WebApplicationClient(settings.NOTION_CLIENT_ID)
authorize_request_url = client.prepare_request_uri(
authorization_base_url, redirect_uri)
print(f"Redirecting to: {authorize_request_url}")
return redirect(authorize_request_url)

@login_required
def notion_redirect(request):

url = request.build_absolute_uri()

client = WebApplicationClient(settings.NOTION_CLIENT_ID)
client.parse_request_uri_response(url)

token_request_params = client.prepare_token_request(token_url, url, redirect_uri)

auth = requests.auth.HTTPBasicAuth(
settings.NOTION_CLIENT_ID, settings.NOTION_CLIENT_SECRET)
print(f"POST to: {token_request_params[0]}")
response = requests.post(
token_request_params[0], headers=token_request_params[1], data=token_request_params[2], auth=auth)
if response.ok:
token_response = client.parse_request_body_response(response.text)

authorization = NotionAuthorization.objects.create(
user = request.user,
access_token = token_response.get("access_token"),
bot_id = token_response.get("bot_id"),
duplicated_template_id = token_response.get("duplicated_template_id", None),
workspace_name = token_response.get("workspace_name"),
workspace_icon = token_response.get("workspace_icon"),
workspace_id = token_response.get("workspace_id"),
owner = token_response.get("owner")
)
authorization.save()

return HttpResponseRedirect(reverse('notion_demo:home'))

Test running the Notion Authorization again #

At this point, you should be able to run through the Notion authorization, and end up back at the home page with the link to authorize.

Use Token to call and API and display results #

Weā€™ll create a simple page to display the title and id of Notion objects that are shared with the integration, along with a link to the item in Notion.

Create NotionClient class #

Letā€™s create a NotionClient class to handle search requests. This is some code that isnā€™t really part of a Django app, that we might want to use from several apps, so weā€™ll create a utils folder and put it there, in utils/notion.py.

import requests
from urllib.parse import urljoin

class NotionClient():
def __init__(self, notion_key):
self.notion_key = notion_key
self.default_headers = {'Authorization': f"Bearer {self.notion_key}",
'Content-Type': 'application/json', 'Notion-Version': '2022-06-28'}
self.session = requests.Session()
self.session.headers.update(self.default_headers)
self.NOTION_BASE_URL = "https://api.notion.com/v1/"

def search(self, query=None, sort=None, filter=None, start_cursor=None, page_size=None):
request_url = urljoin(self.NOTION_BASE_URL, 'search')

data = {}
if query is not None:
data["query"] = query
if sort is not None:
data["sort"] = sort

if filter is not None:
data["filter"] = filter

if start_cursor is not None:
data["start_cursor"] = start_cursor

if page_size is not None:
data["page_size"] = page_size
response = self.session.post(request_url, json=data)

return response

Create Formatter class to handle Notion objects #

We want to have access to 3 things from the Notion search results: the title, the url, and the id. While the url and the id are easy to retrieve, the way Notion stores the title is a bit complicated - we need to identify it by the type of property, and, the text is stored in an array of objects that we want to concatenate together into one string. This would be pretty complicated to handle directly in a template, and itā€™s probably not going to be specific to one view, so weā€™ll make another class to help simplify them. Weā€™ll add this to the same notion.py file as the previous step.

class NotionFormaters():
def get_text(self, text_object):
# Concatenates an object with a text array into plain text
text = ""
obj_type = text_object.get("type")
if obj_type in ["rich_text", "title"]:
for rt in text_object.get(obj_type):
text += rt.get("plain_text")
return text

def simplify_search_response(self, search_response):
# Process the search response to make it easier to handle
notion_objects = search_response.json().get("results")
simplified_results = []
for item in notion_objects:
# Create an object with the properties we care about and a default value for title
simplified_item = {"title": "Untitled", "url": item.get("url"), "id": item.get("id")}
properties = item.get("properties")
# Find the title property and set it on the simplified item
if properties is not None:
for _, value in properties.items():
if value.get("type") == "title":
text = self.get_text(value)
if text is not None and text != "":
simplified_item["title"] = text
break
simplified_results.append(simplified_item)
return simplified_results

Call Search in the View #

And now weā€™ll need a way to display this. We will update the home view so that if there isnā€™t an authorization, weā€™ll show the link, but if there is one, weā€™ll render a different template with the search results. So weā€™ll retrieve the NotionAuthorization object for the logged in user, and if there isnā€™t one, weā€™ll return the same template as before, prompting them to add a Notion workspace, but if there is one, weā€™ll fetch the objects from Notion using the client class and the access token weā€™ve saved for the user.

notion_demo/views.py will now look like this:

from django.shortcuts import render
from django.contrib.auth.decorators import login_required

from notion_oauth.models import NotionAuthorization
from utils.notion import NotionClient, NotionFormaters

@login_required
def home(request):
# Get the authroization for the user, if it exists
authorization = NotionAuthorization.objects.filter(
user=request.user).first()
if authorization is None:
# If there's no NotionAuthorization, show the page where a user can add the authorization
return render(request, 'notion_demo/home.html')
else:
client = NotionClient(authorization.access_token)
formatter = NotionFormaters()
search_response = client.search()
if search_response.ok:
notion_objects = formatter.simplify_search_response(search_response)
return render(request, 'notion_demo/list.html', {'notion_objects': notion_objects})
else:
return render(request, 'notion_demo/list.html',
{'notion_objects': [],
'error_message': f"There was an error retrieving search results, status {search_response.status_code}"
})

Create template to show search results #

Finally, we need to make the template to list the search results. As shown in the view, weā€™ll put it at templates/notion_demo/list.html. Weā€™ll use a for loop to render the Notion results into rows of a table, and use the handy first, last, and empty constructs provided by Django, with some logic to display an error message if present,

{% extends 'base.html' %}

{% block title %}Home{% endblock %}

{% block content %}
<h1>Notion Pages and Databases</h1>
<p>The table below lists the first 100 pages and databases shared with this integration.</p>

{% if error_message %}
<p>{{error_message}}</p>
{%endif%}

{% for item in notion_objects %}
{% if forloop.first %}
<table>
<tr>
<th>Title</th>
<th>Id</th>
</tr>
{% endif %}
<tr>
<td><a href="{{item.url}}">{{item.title}}</a></td>
<td>{{item.id}}</td>
</tr>
{% if forloop.last %}
</table>
{% endif %}
{% empty %}
<p>No shared objects found</p>
{% endfor %}

{% endblock content %}

Test it to see that you can see the search results #

You should now be able to run the code and see a crude table with the items connected to your integration! At this point, we have a complete demo of a Notion integration, but thereā€™s one more step we can take to enhance security.

Screenshot showing successfully retrieving Notion items

Add State Parameter to OAuth flow #

Using the State parameter enhances security by preventing certain kinds of cross site request forgery (CSRF) attacks, and, it can also be used in some scenarios to pass non-sensitive information between different steps of the OAuth process. State should be something hard to guess and verifiable. Depending on your application, this could be a random string stored on the server, or, something like a signed JSON Web Token (JWT) storing some information. For this tutorial, weā€™ll use a string stored in the Django session.

Djangoā€™s session implements a dictionary type API and is by default backed by the database though many configurations are possible. By default, 'django.contrib.sessions' should be in your INSTALLED_APPS and 'django.contrib.sessions.middleware.SessionMiddleware' should be in your MIDDLEWARE in oauth_tutorial/settings.py. If you needed to add any of that, ensure your database has been migrated to add the session table.

Generate a random state value #

First, weā€™ll add a simple function to generate state. Weā€™ll use the python secrets module to generate a secure string that we can use in a URL of 8 bytes of Base64 encoded data (making it about 10-11 characters long). Weā€™ll put this in notion_oauth/views.py though it certainly could live elsewhere if you have integrations with multiple services using OAuth.

# Add import at the top:
import secrets

# Add function at the bottom:
def generate_state():
return secrets.token_urlsafe(8)

Save State to the Session and use in OAuth request #

Now we can go and update the notion_auth_start function to generate a state, set it to the Django session, and add it to the request.

STATE_SESSION_KEY = "notion_state"

@login_required
def notion_auth_start(request):
client = WebApplicationClient(settings.NOTION_CLIENT_ID)

state = generate_state()
request.session[STATE_SESSION_KEY] = state

authorize_request_url = client.prepare_request_uri(
authorization_base_url, redirect_uri, state=state)
return redirect(authorize_request_url)

Validate state when completing OAuth #

And in notion_redirect, itā€™s a simple matter of getting the state from the session and passing it to the prepare_token_request function, and oauthlib handles the validation for us. Per the OAuth spec, the client should invalidate the state after itā€™s used so we use pop to retrieve and remove it from the session.

@login_required
def notion_redirect(request):
url = request.build_absolute_uri()

client = WebApplicationClient(settings.NOTION_CLIENT_ID)
state = request.session.pop(STATE_SESSION_KEY)
client.parse_request_uri_response(url, state=state)
# Rest of function continues as before

Test it by running the authorization again #

To see this in action, weā€™ll need to authorize again, Since we only show the authorize link if thereā€™s not already an authorization, weā€™ll need to go to localhost:8000/admin and delete your notion authorization using the Action drop down so that you can authorize again. You should see the URL of the Notion page now has a state parameter at the end, like state=Fein6Zz0s-8 in https://www.notion.so/install-integration?response_type=code&client_id=...&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fnotion%2Fredirect&state=Fein6Zz0s-8

Thatā€™s a wrap! #

Thatā€™s it! We now have a fully functional, though not very pretty, OAuth connection to Notion from a Django app, which saves the token and calls an API, and weā€™ve enhanced the security by adding the State parameter. This should be able to serve as a template for creating your own Django apps with a Notion integration.