mozilla-django-oidc: OIDC callback state not found in session `oidc_states`!
After logging into the mozilla/oidc-testprovider IDP, a SuspiciousOperation
exception is thrown with message OIDC callback state not found in session `oidc_states`!
. ’
Python version: 3.8.10 Dependencies
asgiref==3.4.1
certifi==2021.5.30
cffi==1.14.6
charset-normalizer==2.0.4
cryptography==3.4.8
Django==3.2.6
idna==3.2
josepy==1.8.0
mozilla-django-oidc==2.0.0
psycopg2==2.9.1
pycparser==2.20
pyOpenSSL==20.0.1
pytz==2021.1
requests==2.26.0
six==1.16.0
sqlparse==0.4.1
urllib3==1.26.6
Reproducing the issue:
- follow instructions to start
testprovider
service locally - create new Django project and app with the code snippets below
- set relevant environment variables to predefined values
- run Django dev server and attempt to log in
settings.py
"""
Django settings for project project.
Generated by 'django-admin startproject' using Django 3.2.6.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '<SECRET_KEY>'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = [
'testrp'
]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'mozilla_django_oidc',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'app',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'project.urls'
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = [
BASE_DIR / 'static',
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': STATICFILES_DIRS,
'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',
],
},
},
]
WSGI_APPLICATION = 'project.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ['POSTGRES_DB'],
'USER': os.environ['POSTGRES_USER'],
'PASSWORD': os.environ['POSTGRES_PASSWORD'],
'HOST': os.environ['POSTGRES_HOST'],
'PORT': os.environ['POSTGRES_PORT'],
}
}
AUTHENTICATION_BACKENDS = [
'mozilla_django_oidc.auth.OIDCAuthenticationBackend',
]
OIDC_RP_CLIENT_ID = os.environ['OIDC_CLIENT_ID']
OIDC_RP_CLIENT_SECRET = os.environ['OIDC_CLIENT_SECRET']
OIDC_RP_SIGN_ALGO = os.environ['OIDC_SIGN_ALGO']
OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ['OIDC_AUTH_ENDPOINT']
OIDC_OP_TOKEN_ENDPOINT = os.environ['OIDC_TOKEN_ENDPOINT']
OIDC_OP_USER_ENDPOINT = os.environ['OIDC_USER_ENDPOINT']
OIDC_RP_IDP_SIGN_KEY = os.environ['OIDC_IDP_SIGN_KEY']
OIDC_RP_SCOPES = 'openid email profile'
LOGIN_REDIRECT_URL = ""
LOGOUT_REDIRECT_URL = ""
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
project urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('oidc/', include('mozilla_django_oidc.urls')),
path('', include('app.urls')),
]
app urls.py
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
urlpatterns = [
path('accounts/login/', auth_views.LoginView.as_view()),
path('', views.index),
]
login.html template
<div>
{% if user.is_authenticated %}
<p>Current user: ❴❴ user.email ❵❵</p>
<form action="{% url 'oidc_logout' %}" method="post">
{% csrf_token %}
<input type="submit" value="Logout">
</form>
{% else %}
<a href="{% url 'oidc_authentication_init' %}">Login</a>
{% endif %}
</div>
About this issue
- Original URL
- State: open
- Created 3 years ago
- Reactions: 5
- Comments: 20 (1 by maintainers)
After a lot of research I was able to identify the source of the problem. I will try to briefly explain why the error occurs and how to solve it.
By default, the first endpoint called is
/oidc/authenticate/
which redirects to a page that will be responsible for logging in and then redirects again to/oidc/callback/
sending some information.We can divide this into 2 steps: 1 - Call the
/oidc/authenticate/
endpoint by sending a payload containing a randomly generatedstate
; 2 - Receive in/oidc/callback/
via redirect the authentication code and thestate
generated in the previous step.In the first step, the library generates a very important value, the
state
and save it in the session. In the second step, thestate
value received after the redirect is checked, and if not found in the session, raises this errorOIDC callback state not found in session oidc_states!
. Reference: https://github.com/mozilla/mozilla-django-oidc/blob/master/mozilla_django_oidc/views.py#L88This whole process happens very quickly. The problem is there, when there are too many simultaneous requests to
/oidc/authenticate/
, depending on which session engine you are using, the session cannot be saved in time before the second step.I thought of some ways to solve this problem, and I will quote the simplest ones: Warning: These codes are mere examples, implement in a way that meets your needs.
The less elegant way: Inherit the
OIDCCallbackClass
class and override theget
method by adding atime.sleep(1)
and configure the project to use your class instead of the default one.example/views.py
settings.py
Change the SESSION_ENGINE: By default django uses the database to save sessions, there are several positives to using it, but in our case, we need the session to be saved very quickly. I recommend that you read about it before making any changes: https://docs.djangoproject.com/en/4.0/topics/http/sessions/
settings.py
According to django’s own documentation, this is the most performant engine, especially if you use Redis. This is the way I consider the most elegant and it solved my problems.
This is the summary of my studies, I may be wrong or incomplete, I would love to receive opinions. Hope this helps.
Any update on this issue? We see this exception quite regularly (a few times a day). We’re somewhat reluctant to change our Session Backend as described in https://github.com/mozilla/mozilla-django-oidc/issues/435#issuecomment-1036372844; especially to a purely in-memory one.
After reading up on Django Session Management, the comment from @MrJizzler (https://github.com/mozilla/mozilla-django-oidc/issues/435#issuecomment-1025231256) seems totally reasonable. The very same problem is listed in the Django documentation (https://docs.djangoproject.com/en/3.2/topics/http/sessions/#when-sessions-are-saved): The implementation of
add_state_and_nonce_to_session
alters theoidc_states
dict in the session if it is already present (e.g. to a previous/parallel login flow) and thus the session may not be stored.I believe to have found one way this can happen:
When a user sends two requests requiring authentication roughly at the same time, e.g. because they reopened their browser with old tabs, and the session refresh middleware is being used, both requests try to modify the session to add their state, however only as there is no locking, the changes of one of the two request are overwritten by the other request.
We see this issue on our site and added some more logging to track it down, and we see patterns like this from clients:
/object/foo
Adds statestate_a
and returns 302 redirecting to identity provider because oidc login is too old/object/bar
Adds statestate_b
and returns 302 redirecting to identity provider because oidc login is too old/oidc/callback/?code=bla&state=state_a
triggers “OIDC callback state not found”According to our debug log, request 1 and request 2 each add their state to the session.
However, in the log output for request 3, we see that
state_b
is present inoidc_states
, butstate_a
is missing.Looking closer at request 2, we can see that
state_a
is missing in itsoidc_states
.So I would assume that something like the following is happening:
oidc_states = {}
oidc_states = {}
oidc_states = { 'state_a': {...} }
is in the backendoidc_states = { 'state_b': {...} }
is in the backendWhile actually
oidc_states = { 'state_a': {...}, 'state_b': {...} }
should be in the backend.I am not sure how this should best be solved, and I believe that the specifics might also depend on the Session Backend that is being used.
Not sure if this fixes the issue, but the
def add_state_and_nonce_to_session
function inutils.py
does not callrequest.session.modified = True
as described here https://docs.djangoproject.com/en/3.2/topics/http/sessions/#when-sessions-are-saved. This allows for multiple session states to be saved.You needs add class of authentication backend and orverride create_user method to add
is_staff
oris_superuser
toTrue
. AddAUTHENTICATION_BACKENDS
in settings.py refering to you custom class backent Example:In settings.py