An Opinionated Guide to DRF OAuth
Tags: django, oauth
Published: 2023-08-03 13:07:50

In this tutorial I'll demonstrate an opinionated approach to building social authentication into a Django app using REST Framework. I say opinionated because it avoids using any social auth app, relying only on **django.contrib.auth** and the standalone [requests-oauthlib](https://requests-oauthlib.readthedocs.io/en/latest/) module. After experimenting with social auth apps, I decided I preferred this approach because it felt distinctly less magical and easier to extend.

Assuming you're sold on this idea, how does it work then?

### Getting Started

Dive in straightaway with creating a new Django project and app.

```bash

django-admin startproject drf_auth_tutorial
cd drf_auth_tutorial
python manage.py startapp tutorial
```

We have very few dependencies for this tutorial. In fact, we just need DRF and **requests-oauthlib** installed. We'll also use **django-sslserver** to enable SSL during local development. Our **requirements.txt** file should look like below.

```bash

django-sslserver==0.22
djangorestframework==3.14.0
requests-oauthlib==1.3.1
```

Next we need to add **rest\_framework** and our app to the **INSTALLED\_APPS** list. We'll also need **rest\_framework.authtoken** to allow issuing authentication tokens once a successful signup or login has happened. You'll also want to add **sslserver** for local development, because the OAuth2 flow will require a secure connection, even during local testing.

```python

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "sslserver",
    "rest_framework",
    "rest_framework.authtoken",
    "tutorial.apps.TutorialConfig",
]
```

For this tutorial we'll set up OAuth2 for both Google and GitHub. Before going any further, we'll need credentials from both before we can start testing.

To set up Google first go to the [Google Cloud console](https://console.cloud.google.com/home/dashboard) and create a project. Once the project is created, proceed to the [Credentials page](https://console.cloud.google.com/apis/credentials) and click Create Credentials. You should choose **OAuth Client ID** and then choose **Web Application** as the credential type. Here we'll configure what domains and URLs are allowed. The allowed redirect URLs must match the routes created in **urls.py** exactly, or Google will display an error message to your users.


![Google credentials setup](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aqo86gknjgyka0aiaii1.png)


We'll go through a similar process for setting up GitHub credentials. For GitHub, go to Settings and then to [Developer Settings (bottom left of the page)](https://github.com/settings/developers), and then select OAuth Apps. Configure your OAuth app similarly to what's shown below. You can put whatever you'd like in the Homepage URL field.


![GitHub credentials setup](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/guro6y32hd0ycxf90fj5.png)

For both Google and GitHub you should have a client ID and secret. These values should go into a **.env** file that is not checked into source control. To pull these values into Django, we'll update **settings.py** to retrieve them from the environment. Add the following to the settings file.

```python

GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
GOOGLE_CLIENT_SECRET = os.environ["GOOGLE_CLIENT_SECRET"]
GOOGLE_TOKEN_URL = "https://www.googleapis.com/oauth2/v4/token"
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"

GITHUB_CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
GITHUB_CLIENT_SECRET = os.environ["GITHUB_CLIENT_SECRET"]
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
```

Here's an overview of the project structure before we dive into specific files.

```bash

├── db.sqlite3
├── dev.env
├── drf_auth_tutorial
│   ├── asgi.py
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── manage.py
├── requirements.txt
└── tutorial
    ├── admin.py
    ├── apps.py
    ├── __init__.py
    ├── models.py
    ├── tests.py
    ├── urls.py
    ├── utils.py
    └── views
        ├── github_oauth_login_callback.py
        ├── github_oauth_login.py
        ├── github_oauth_signup_callback.py
        ├── github_oauth_signup.py
        ├── google_oauth_login_callback.py
        ├── google_oauth_login.py
        ├── google_oauth_signup_callback.py
        ├── google_oauth_signup.py
        └── __init__.py

3 directories, 25 files
```

Each OAuth2 provider has four Views associated with them, with one pair of files implementing login and the other the signup flow. As we look into each of these files, we'll see that they contain only a small amount of code. This is because most of the logic is reusable and centralized in the **tutorial/utils.py** file.

We'll start off with looking at the Google signup flow.

```python

from django.shortcuts import redirect
from django.urls import reverse

from rest_framework.views import APIView
from tutorial.utils import google_setup

class GoogleOAuth2SignUpView(APIView):
    def get(self, request):
        # The redirect_uri should match the settings shown on the GCP OAuth config page.
        # The call to build_absolute_uri returns the full URL including domain.
        redirect_uri = request.build_absolute_uri(reverse("google_signup_callback"))
        return redirect(google_setup(redirect_uri))
```

When the user visits the **/signup/google/** URL they are automatically redirected to the Google hosted auth page, where they'll select which account they want to use with your application.

Google will then send the user back to the **redirect\_uri** passed to the **google\_setup** function in the above view. The URL will also include an OAuth code that'll be used to obtain an auth token in the next step. The **google\_setup** function belongs to the shared **utils.py** file that I mentioned earlier.

The **utils.py** file contains all of the code related to using the **requests-oauthlib** library. I've centralized the code there because the process of logging in and signing up are largely the same.

Here I'll share the **google\_setup** and **google\_callback** methods that are used in both the login and signup process.

```python

from django.conf import settings
from requests_oauthlib import OAuth2Session


def google_setup(redirect_uri: str):
    session = OAuth2Session(
        settings.GOOGLE_CLIENT_ID,
        redirect_uri=redirect_uri,
        scope=[
            "openid",
            "https://www.googleapis.com/auth/userinfo.email",
            "https://www.googleapis.com/auth/userinfo.profile",
        ],  
    )   

    authorization_url, _ = session.authorization_url(
        settings.GOOGLE_AUTH_URL, access_type="offline", prompt="select_account"
    )   

    return authorization_url


def google_callback(redirect_uri: str, auth_uri: str):
    session = OAuth2Session(
        settings.GOOGLE_CLIENT_ID,
        redirect_uri=redirect_uri,
        scope=[
            "openid",
            "https://www.googleapis.com/auth/userinfo.email",
            "https://www.googleapis.com/auth/userinfo.profile",
        ],  
    )   

    session.fetch_token(
        settings.GOOGLE_TOKEN_URL,
        client_secret=settings.GOOGLE_CLIENT_SECRET,
        authorization_response=auth_uri,
    )   

    user_data = session.get("https://www.googleapis.com/oauth2/v1/userinfo").json()
    return user_data
```

Once the user has selected which account they want to use, Google will redirect their browser to the URL we built in the **GoogleOAuth2SignUpView** class.  This URL must match one of the authorized redirect URLs set on the GCP config page.  Google will send extra query parameters along with the request, including an OAuth code.  

This OAuth code is embedded in what I've called the `auth_uri` in the class seen below, which handles the redirected request.

```python
from django.contrib.auth.models import User

from django.http import JsonResponse
from django.urls import reverse

from rest_framework.authtoken.models import Token
from rest_framework.views import APIView

from tutorial.utils import google_callback
from tutorial.models import UserProfile

class GoogleOAuth2SignUpCallbackView(APIView):
    def get(self, request):
        redirect_uri = request.build_absolute_uri(reverse("google_signup_callback"))
        auth_uri = request.build_absolute_uri()

        user_data = google_callback(redirect_uri, auth_uri)

        # Use get_or_create since an existing user may end up signing in
        # through the sign up route.
        user, _ = User.objects.get_or_create(
            username=user_data["email"],
            defaults={"first_name": user_data["given_name"]},
        )

        # Populate the extended user data stored in UserProfile.
        UserProfile.objects.get_or_create(
            user=user, defaults={"google_id": user_data["id"]}
        )

        # Create the auth token for the frontend to use.
        token, _ = Token.objects.get_or_create(user=user)

        # Here we assume that once we are logged in we should send
        # a token to the frontend that a framework like React or Angular
        # can use to authenticate further requests.
        return JsonResponse({"token": token.key})
```

Note that we've extended the built-in Django User model with our own model named **UserProfile** that stores social auth info.  In this tutorial app, every User will have an associated **UserProfile** in a one-to-one relationship.  The **UserProfile** is used to store the **google_id** and **github_id** values, and at least one of them must be populated.

It's also worth noting that if an existing user tries to go through sign-up again, they are simply logged back in.  You could of course choose to do something different in this case, like raise an error and inform the user that they are already signed up.

```python
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
from django.db import models

class UserProfile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    google_id = models.CharField(max_length=255, unique=True, null=True)
    github_id = models.CharField(max_length=255, unique=True, null=True)

    def clean(self):
        if self.google_id is None and self.github_id is None:
            raise ValidationError("One of google_id or github_id must be set.")
```

Now that we've seen how the sign-up process with Google works, I'll show how little the login process actually differs.

```python
from django.shortcuts import redirect
from django.urls import reverse

from rest_framework.views import APIView
from tutorial.utils import google_setup

class GoogleOAuth2LoginView(APIView):
    def get(self, request):
        # The redirect_uri should match the settings shown on the GCP OAuth config page.
        # The call to build_absolute_uri returns the full URL including domain.
        redirect_uri = request.build_absolute_uri(reverse("google_login_callback"))
        return redirect(google_setup(redirect_uri))
```

If this looks remarkably familiar to you, it's because it is!  The only difference here from the **GoogleOAuth2SignUpView** class is the redirect URI that is used.  Instead of getting sent back to the **google_signup_callback** we are sending the user to the **google_login_callback** URL.

We need a separate view for the login callback because the logic is of course slightly different.  Namely, if there is no existing user associated with the **email**, we should return an error.

```python
from django.contrib.auth.models import User

from django.http import JsonResponse
from django.urls import reverse

from rest_framework.authtoken.models import Token
from rest_framework.views import APIView

from tutorial.utils import google_callback

class GoogleOAuth2LoginCallbackView(APIView):
    def get(self, request):
        redirect_uri = request.build_absolute_uri(reverse("google_login_callback"))
        auth_uri = request.build_absolute_uri()

        user_data = google_callback(redirect_uri, auth_uri)

        try:
            user = User.objects.get(username=user_data["email"])
        except User.DoesNotExist:
            return JsonResponse(
                {"error": "User does not exist. Please sign up first."}, status=400
            )

        # Create the auth token for the frontend to use.
        token, _ = Token.objects.get_or_create(user=user)

        # Here we assume that once we are logged in we should send
        # a token to the frontend that a framework like React or Angular
        # can use to authenticate further requests.
        return JsonResponse({"token": token.key})
```

Notice that here we're reusing the **google_callback** again, so that the logic for requesting user details is not repeated.

To tie it all together, I'll show the `urls.py` for the tutorial app below.

```python
from django.urls import path

from .views.google_oauth_signup import GoogleOAuth2SignUpView
from .views.google_oauth_signup_callback import GoogleOAuth2SignUpCallbackView
from .views.google_oauth_login import GoogleOAuth2LoginView
from .views.google_oauth_login_callback import GoogleOAuth2LoginCallbackView

from .views.github_oauth_signup import GitHubOAuth2SignUpView
from .views.github_oauth_signup_callback import GitHubOAuth2SignUpCallbackView
from .views.github_oauth_login import GitHubOAuth2LoginView
from .views.github_oauth_login_callback import GitHubOAuth2LoginCallbackView

urlpatterns = [
    path("signup/google/", GoogleOAuth2SignUpView.as_view(), name="google_signup"),
    path(
        "google/callback/signup",
        GoogleOAuth2SignUpCallbackView.as_view(),
        name="google_signup_callback",
    ),
    path("login/google/", GoogleOAuth2LoginView.as_view(), name="google_login"),
    path(
        "google/callback/login",
        GoogleOAuth2LoginCallbackView.as_view(),
        name="google_login_callback",
    ),
    path("signup/github/", GitHubOAuth2SignUpView.as_view(), name="github_signup"),
    path(
        "github/callback/signup",
        GitHubOAuth2SignUpCallbackView.as_view(),
        name="github_signup_callback",
    ),
    path("login/github/", GitHubOAuth2LoginView.as_view(), name="github_login"),
    path(
        "github/callback/login/",
        GitHubOAuth2LoginCallbackView.as_view(),
        name="github_login_callback",
    ),
]
```

I hope this has been a helpful tutorial for OAuth with Django REST Framework!  I'm not showing the GitHub views here because they are very similar to the Google process, but if you'd like to see all of the code, you can always [visit the GitHub repo](https://github.com/zchtodd/drf-auth-tutorial/tree/main) for the tutorial project.