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.
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.
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')
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.html
with 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.py
in part 1.
At this point, your folders should look like this:
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',
],
},
},
]
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'))
]
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.
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.
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 %}
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'))
]
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')
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!
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/.
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.
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.
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
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_KEY
with 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ā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", "")
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
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']
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
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'))
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'))
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.
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.
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
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
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}"
})
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 %}
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.
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.
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)
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)
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
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 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.