Connecting a django project to an identity provider

In this tutorial, you will learn how to use this library along with an identity provider (IP) to setup a single-sign-on system. For the purpose of this tutorial, we will use Keycloak as an IP.

Requirements

To use this library, make sure that you meet the following requirements:

  • django>=5.2

  • python>=3.10

  • the Session middleware is enabled

  • a cache backend for django (redis, etc.)

  • an OIDC-compliant identity provider

Installation

To install this library the easiest way is to use the pypi package

pip install django-pyoidc

Configuring your SSO

Next, you should configure a client in your identity provider configuration interface.

Warning

Incorrect configuration of your Identity Provider can create security issues. Please make sure you understand the values you input and their impact on the security level of your system.

We provide instructions for Keycloak (version 18 and more), a free and open source Identity Provider maintained by Red Hat.

Keycloak

Start by connecting as your realm admin on the administration interface.

We will create a new client which supports the ‘Authorization Code Flow’. Go to the client list of your realm and click on “Create client”

Screenshot of the client list from a Keycloak instance

Set the Client type to OpenID Connect and choose a meaningful Client ID. The other options do not matter for this tutorial.

Screenshot of the first page of a client configuration form from Keycloak

On the second page, enable Client authentication and the Standard Flow (also named Authorization Code Flow which is the one that we want).

Screenshot of the second page of a client configuration form from Keycloak

Click on save and your client should be visible in the client list.

You can now configure your URLs. In the following example, the Django application is hosted at app.local:8082.

We configure our client URLs as such:

  • Root URL and Home URL redirects to the root of our application http://app.local:8082

  • With Valid redirect URIs we allow the user to be redirected to our application, or the one listening on localhost:9091 and 127.0.0.1:9091 (for debug purposes)

  • With Valid post logout redirect URIs the user can be redirected to our application after logout: http://app.local:8082/*

  • Web origins is set to + which allows (through CORS) all origins from the redirect URIs

TODO: using a 2nd app at localhost:9091 is confusing, remove that, use a localhost:something, better

Screenshot of url configuration page for Keycloak client

Take note of your Client ID and visit the Credentials Page to find your Client Secret. You will need both to configure the OIDC connector.

Screenshot of the Credentials page from a test client

Finally, click on Realm Settings in the left menu, and scroll down to the Endpoints section. Copy the OpenID Endpoint Configuration URL as you will need it later (this is the autodiscovery URL).

Congratulation, your Keycloak configuration is complete! 🎉

Other Identity provider

Configuring your Django project

Install the application

It is now time to configure your Django project.

First, add the library app (django_pyoidc) to your django applications, after django.contrib.sessions and django.contrib.auth:

settings.py
INSTALLED_APPS = [
    "django.contrib.auth",
    "django.contrib.sessions",
    ...
    "django_pyoidc"
]

Warning

Do not forget later to run the migrations! This module requires some extra database storage tables.

Configure a cache backend

You must have a cache backend for this library to work! The OIDC protocol is very stateful and we use Django cache system to store data. If you want to understand why, you can read the Cache management page.

For the sake of this tutorial, you can use this cache management snippet (it should be pasted in your settings.py):

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
        "LOCATION": "unique-snowflake",
    }
}

Warning

Do not use those settings in production! Go read the django documentation for more details.

Configure the library

First, make sure that the Session middleware is enabled.

We will use django_pyoidc provider system to generate the library configuration and views.

When using provider, you must provide 4 settings:

  • the provider class to use

  • the OIDC client ID: this is your identifier on the IP side (this is not a user account, this must be a client in the OIDC terminology)

  • the OIDC client secret: this is your secret on the IP side

  • the OIDC discovery URL: this url allows us to discover the various endpoint of the identity provider, easing the configuration

You must also define a provider name that will be used with other classes from this library. In the following example, we define a provider named sso which uses Keycloak18Provider and fetches it’s credential from two environment variables:

settings.py
DJANGO_PYOIDC = {
    # This is the name that your identity provider will have within the library
    "sso": {
        # change the following line to use your provider
        "provider_class": "django_pyoidc.providers.keycloak_18.Keycloak18Provider",

        # your secret should not be stored in settings.py, load them from an env variable
        "client_secret": os.getenv("SSO_CLIENT_SECRET"),
        "client_id": os.getenv("SSO_CLIENT_ID"),

        # Your autodiscovery url should go here
        "provider_discovery_uri": "https://keycloak.example.com/auth/realms/fixme",

        # This setting allow the library to cache the provider configuration auto-detected using
        # the `provider_discovery_uri` setting
        "oidc_cache_provider_metadata": True,
    },

When you need to configure a setting for your identity provider, it means that you have to update the dictionary in this setting. For example, if you were to configure oidc_paths_prefix for your Keycloak provider, you would add oidc_paths_prefix : <your value> to the sso dictionary.

Please note that drf is a reserved provider name (see Configuring django_rest_framework for more details)

Copy-paste this snippet to your settings.py. Make sure to modify provider_discovery_uri.

Generate the URLs

We provide a facility that generates all the views needed for a provider. This is implemented by the OIDCHelper class. This class reads the DJANGO_PYOIDC setting and uses it’s configuration to generate views.

To use it, you must instantiate it with op_name=<the name of your identity provider>.

Here is how to do it for our tutorial:

urls.py
from django_pyoidc.helper import OIDCHelper

# `op_name` must be the name of your identity provider as used in the `DJANGO_PYOIDC` setting
oidc_helper = OIDCHelper(op_name="sso")

urlpatterns = [
    path(
        "auth/",
        include((oidc_helper.get_urlpatterns(), "django_pyoidc"), namespace="auth"),
    ),
]

This will create 4 views in your URL configuration. They all have a name that derives from the op_name that you used to create your provider.

  • a login view named <op_name>-login, here handled on the /auth/login path

  • a logout view named <op_name>-logout, here handled on the /auth/logout path

  • a callback view named <op_name>-callback, here handled on the /auth/callback path

  • a backchannel logout view named <op_name>-backchannel-logout, here handled on the /auth/backchannel-logout path

Tip

You can override the naming behavior by configuring the setting oidc_paths_prefix of your identity provider. The view names would then be <oidc_paths_prefix>_<view_name>.

You should now be able to use the view names from this library to redirect the user to a login/logout page.

Configuring django_rest_framework

When using OIDC to authenticate an API, things are a little bit different than on a full stack website:

  • we do not want to redirect users on login pages, or to manage logout

  • we are receiving OIDC Bearer tokens – access tokens– (generated from other clients of the SSO) and the task is mainly to check that this token is valid and extract the user from it.

To configure django_rest_framework, you must create a special provider named drf. The configuration is similar to the one made in Configure the library.

settings.py
DJANGO_PYOIDC = {
    # This is the name that your identity provider will have within the library
    "drf": {
        "provider_class": "django_pyoidc.providers.keycloak_18.Keycloak18Provider",
        "client_secret": os.getenv("SSO_CLIENT_SECRET"),
        "client_id": os.getenv("SSO_CLIENT_ID"),
        "provider_discovery_uri": os.getenv(
            "SSO_ENDPOINT", "https://keycloak.example.com/auth/realms/fixme"
        ),
        "oidc_cache_provider_metadata": True,
    },

Note

Usually your application should request a different client_id for the apimode (like a my-app-full client_id for a confidential classical OIDC client and a my-app-api client_id for a bearer-only OIDC client in Keycloak). But if you have only one client_id it’s OK to simply make a copy for the settings.

Once you declared those settings, you can configure DEFAULT_AUTHENTICATION_CLASSES to use django_pyoidc.drf.authentication.OIDCBearerAuthentication to use this authentication class on all your views:

settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "django_pyoidc.drf.authentication.OIDCBearerAuthentication"
    ]
}

You can also set this class on a per-view basis using the authentication_classes attribute:

views.py
from django_pyoidc.drf.authentication import OIDCBearerAuthentication

class ExampleViewSet(ModelViewSet):
    authentication_classes = [OIDCBearerAuthentication]

This class looks up the OIDC provider named drf in the DJANGO_PYOIDC setting. As such, you can only have one provider for all your API authentication, as you can not define two drf keys in the settings.

Tip

Look up the drf documentation for more details about authentication classes.

Tip

Taking a look at the documentation of use_introspection_on_access_tokens might be a good idea if you run into some issues

Configuring drf spectacular (swagger) integration

After settings drf-spectacular with swagger integration, you can setup the swagger authentication module to use OIDC.

Note

To our knowledge, redoc is no supported.

Usually we have two OIDC clients for a django API :

  • one is the client used to check incoming token (the drf client in the tutorial above)

  • one is a full client used to authenticate users on the admin page

You can configure swagger to use your full client to generate OIDC tokens. The setup is quite easy, you just need to tell swagger about your client credentials.

In your settings.py add :

settings.py
SPECTACULAR_SETTINGS = {
    "SWAGGER_UI_OAUTH2_CONFIG": {
        "clientId": os.getenv("SSO_CLIENT_ID"), # adapt this to your projet
        "clientSecret": os.getenv("SSO_CLIENT_SECRET"), # adapt this to your project
        "scopes": [], # optional
    },
}

You will also need to add a special view to register the token in your browser after you’ve authenticated succesfully. In the following example, you should have some equivalent for api/schema and api/docs.

urls.py
from drf_spectacular.views import (
    SpectacularAPIView,
    SpectacularSwaggerOauthRedirectView,
    SpectacularSwaggerView,
)

urlpatterns += [
    # The following 2 urls need to be configured according to drf-spectacular documentation
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    path(
        "api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"
    ),
    # This is the view that you need to add
    path(
        "api/docs/oauth2-redirect.html", # FIX this so that this view is next to the SpectacularSwaggerView
        CustomSpectacularView.as_view(),
        name="swagger-ui-oauth",
    )
]

You should now have an authorize button on the the top right corner of your swagger page.

Screenshot of the swagger home page with a green authorize button

After clicking on it, a popup opens. Scroll top the top. You should reach a section named “openIdConnect (OAuth2, authorization_code) “. The client_id and client_secret shoud already be filled from your env variables. Scroll down until you see an authorize button. Click on it.

Screenshot of the popup

After clicking on authorize, you are redirected to to your SSO login page. After login you are redirected to the special swagger page which stores the token to be used on the main swagger page.

You’re done ! 🎉

Integrating django-pyoidc with the admin site

To use this library to authenticate users into the admin site, you must first configure a provider.

We will assume that you configured a provider name ‘sso’ for the remaining of this tutorial.

To integrate OIDC with the admin site :

  • you need to have some views implementing OIDC behaviour and logging users into django authentication backend (this is provided by this library)

  • you need to change the admin login page to redirect to the OIDC login view : this is the purpose of this tutorial

To change the admin login page you have two options :

  1. declare a template that overrides the login page of the admin

  2. plug a custom template into the admin site

We will go for the second option because we think that it is the cleanest. If the admin template organization was to change, our login page will still be used.

This is the login page that will be implemented in this tutorial :

Screenshot of the admin login page patched with a single login button

Replace the login form with a button redirecting to the SSO

First, we will create a custom login template that replaces the login form with a button redirecting the user to our OIDC Login View.

Why do we use a button instead of silently redirecting to the login view ? Because the user might not have the permission to view the admin site and would be stuck in a redirection loop if we redirect them automatically.

Create the following template in my_project/templates/my_project/login.html :

my_project/templates/my_project/login.html
{% extends "admin/base_site.html" %}
{% load i18n static %}

{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/login.css" %}">
{{ form.media }}
{% endblock %}

{% block bodyclass %}{{ block.super }} login{% endblock %}

{% block usertools %}{% endblock %}

{% block nav-global %}{% endblock %}

{% block nav-sidebar %}{% endblock %}

{% block content_title %}{% endblock %}

{% block nav-breadcrumbs %}{% endblock %}

{% block content %}
{% if form.errors and not form.non_field_errors %}
<p class="errornote">
{% blocktranslate count counter=form.errors.items|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %}
</p>
{% endif %}

{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<p class="errornote">
    {{ error }}
</p>
{% endfor %}
{% endif %}

<div id="content-main">

{% if user.is_authenticated %}
<p class="errornote">
{% blocktranslate trimmed %}
    You are logged in as {{ username }} but you are not allowed to access this page. Would you like to logout to try with an other user account ?
{% endblocktranslate %}

<form action="{% url "sso-logout" %}" method="GET" id="login-form">{% csrf_token %}
  <div class="submit-row">
    <input type="submit" value="{% translate "Logout" %}">
  </div>
</form>

</p>
{% else %}
<form action="{% url "sso-login" %}" method="GET" id="login-form">{% csrf_token %}
  <div class="submit-row">
    <input type="submit" value="{% translate "Login" %}">
  </div>
</form>
{% endif %}



</div>
{% endblock %}

We extend the base admin template to re-use style components. If the user is authenticated and is on this page, it means that login failed and we show a logout button.

Creating a custom admin site

Create a sites.py file somewhere suitable. We tend to put it next to our settings module as we find that this configuration is more ‘project related’ than ‘application related’.

sites.py
from django.contrib.admin import AdminSite


class OIDCLoginAdminSite(AdminSite):
    login_template = "my_project/login.html" # we will declare this template later
    site_header = "Administration Site"


site = OIDCLoginAdminSite(name="my-project-admin")

# We need a callable for the next step
def get_site():
    return site

Next, we need to register this admin site with the admin config. Create an admin_config.py module somewhere suitable (next to the settings for example) :

admin_config.py
from django.contrib.admin.apps import AdminConfig


class CustomAdmin(AdminConfig):
    # Change my_project with the name of the package holding your sites.py module
    default_site = "my_project.sites.get_site"

Now you have the following files next to each other :

  • settings.py

  • sites.py

  • admin_config.py

To use the custom login page, you must import the admin app from your custom config instead of django.contrib.auth in your INSTALLED_APPS setting :

setting.py
INSTALLED_APPS = [
    # The following lines replaces 'django.contrib.auth'
    "my_project.admin_config.CustomAdmin",
]

And you are done !