Search code examples
djangooauthdjoser

Google OAuth (DRF + Djoser) "Invalid state has been provided." after POST request with state and code


I am following this video tutorial and trying to implement Google social authentication using DRF, djoser and React.

Steps which leads to an error:

  1. Send GET request:
http://localhost:8000/auth/o/google-oauth2/?redirect_uri=http://localhost:8000

The response looks like this (I modified the response slightly because I wasn't sure hat this url is safe to share)

   {
       "authorization_url": "https://accounts.google.com/o/oauth2/auth?client_id=836198290956-fe0ilujf6e23l882oumgkufi8qm6fg3m.apps.googleusercontent.com&redirect_uri=http://localhost:8000&state=eNwMFCmEplYgbUTTP9nnrQ6MduAPxzDY&response_type=code&scope=https://www.googleapis.com/auth/userinfo.email+https://www.googleapis.com/auth/userinfo.profile+openid+openid+email+profile"
   }
  1. After I enter the response to the browser, it redirects me to google sign in page. Then I select my account, then I press continue. I am now redirected to localhost:8000 with this url L
http://localhost:8000/?state=eNwMFCmEplYgbUTTP9nnrQ6MduAPxzDY&code=4%2F0AcvDMrB6f3ZQuTD563Vxriu2n0VHmLEOHnDRqC6jD5BRm068jj2tyExxfZZJDFLAtcwYLg&scope=email+profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&authuser=0&hd=circle.help&prompt=consent

My problem shows up here.

  1. I take state and code parameters from this url, and make POST request using Postman (without any body, just url, but I also set Content-Type header to application/x-www-form-urlencoded) to this url:
localhost:8000/auth/o/google-oauth2/?state=eNwMFCmEplYgbUTTP9nnrQ6MduAPxzDY&code=4%2F0AcvDMrB6f3ZQuTD563Vxriu2n0VHmLEOHnDRqC6jD5BRm068jj2tyExxfZZJDFLAtcwYLg

But in response I receive this (Here is my problem):

{
   "non_field_errors": [
      "Invalid state has been provided."
    ]
}

I tried to debug it, and found out that the cause lies in this module:

# venv/lib/python3.11/site-packages/social_core/backends/oauth.py

class OAuthAuth(BaseAuth):
    ...
    # Other methods
    def validate_state(self):
        """Validate state value. Raises exception on error, returns state
        value if valid."""
        if not self.STATE_PARAMETER and not self.REDIRECT_STATE:
            return None
        state = self.get_session_state()
        request_state = self.get_request_state()
        if not request_state:
            raise AuthMissingParameter(self, "state")
        elif not state:
            raise AuthStateMissing(self, "state")
        elif not constant_time_compare(request_state, state):
            raise AuthStateForbidden(self)
        else:
            return state
    ...
    # Other methods

In my case, in OAuthAuth.validate_state() method, the state variable is different from request_state variable, while request_state (from OAuthAuth.validate_state()) is the same as state (from urls) in both above urls, but the state (from OAuthAuth.validate_state()) is totally different. I can't figure out where it comes from, why do

self.get_request_state()

returns a different state that in url? Perhaps I am doing something wrong and I should pass some cookie in Postman?

UPD: I tried manually assign the correct state value from url to state = self.get_session_state() and everything worked fine, I just do not know why self.get_session_state() returns incorrect value?

Here are the list of my installed packages:

asgiref==3.8.1
certifi==2024.7.4
cffi==1.16.0
charset-normalizer==3.3.2
cryptography==42.0.8
defusedxml==0.8.0rc2
Django==5.0.7
django-filter==24.2
django-templated-mail==1.1.1
djangorestframework==3.15.2
djangorestframework-simplejwt==5.3.1
djoser==2.2.3
idna==3.7
Markdown==3.6
oauthlib==3.2.2
psycopg==3.2.1
psycopg2-binary==2.9.9
pycparser==2.22
PyJWT==2.8.0
python3-openid==3.2.0
requests==2.32.3
requests-oauthlib==2.0.0
social-auth-app-django==5.4.2
social-auth-core==4.5.4
sqlparse==0.5.1
typing_extensions==4.12.2
urllib3==2.2.2

Here is my settings.py

SECRET_KEY = "django_secret_key"

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []

# Application definition

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",

    # Third party apps
    "rest_framework",
    "djoser",
    "social_django",
    "rest_framework_simplejwt",
    "rest_framework_simplejwt.token_blacklist",  # more smooth with migration

    # My apps
    "accounts"
]

MIDDLEWARE = [
    "social_django.middleware.SocialAuthExceptionMiddleware",
    "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 = "djSocAuth.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        # "DIRS": [BASE_DIR / 'templates'],  # os.path.join(BASE_DIR, "build")
        "DIRS": [os.path.join(BASE_DIR, "build")],  # os.path.join(BASE_DIR, "build")
        "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",
                "social_django.context_processors.backends",
                "social_django.context_processors.login_redirect"
            ],
        },
    },
]

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": {
        "rest_framework.permissions.IsAuthenticated"
    },
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
}

AUTHENTICATION_BACKENDS = (
    "social_core.backends.google.GoogleOAuth2",  # Enable using google OAuth 2
    "django.contrib.auth.backends.ModelBackend",  # Enable logining via email and password
)

SIMPLE_JWT = {
    'AUTH_HEADER_TYPES': ('JWT',),
    "ACCESS_TOKEN_LIFETIME": timedelta(days=10000),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=10000),
    "AUTH_TOKEN_CLASSES": (
        "rest_framework_simplejwt.tokens.AccessToken",
    )
}
from djoser.social.token.jwt import TokenStrategy

DJOSER = {
    "LOGIN_FIELD": "email",
    "USER_CREATE_PASSWORD_RETYPE": True,  # confirm password field
    "USERNAME_CHANGED_EMAIL_CONFIRMATION": True,  # whenever username is changed - confirmation email is sent
    "PASSWORD_CHANGED_EMAIL_CONFIRMATION": True,
    "SET_USERNAME_RETYPE": True,
    "SET_PASSWORD_RETYPE": True,
    "PASSWORD_RESET_CONFIRM_URL": "password/reset/confirm/{uid}/{token}",
    "USERNAME_RESET_CONFIRM_URL": "email/reset/confirm/{uid}/{token}",
    "ACTIVATION_URL": "activate/{uid}/{token}",  # this should be on frontend, --> auth/users/activation/
    "SEND_ACTIVATION_EMAIL": True,
    "SOCIAL_AUTH_TOKEN_STRATEGY": "djoser.social.token.jwt.TokenStrategy",
    "SOCIAL_AUTH_ALLOWED_REDIRECT_URIS": [
        "http://localhost:8000"
    ],
    "SERIALIZERS": {
        "user_create": "accounts.serializers.UserCreateSerializer",
        "user": "accounts.serializers.UserCreateSerializer",
        "current_user": "accounts.serializers.UserCreateSerializer",
        "user_delete": "djoser.serializers.UserDeleteSerializer",

    }
}

# Google config
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = "my_key"
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = "my_secret"
SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = [
    "https://www.googleapis.com/auth/userinfo.email",  # retrieve email
    "https://www.googleapis.com/auth/userinfo.profile",
    "openid"
]  # when people go to sign up and sing in, they are going to retrieve some data from their accounts
SOCIAL_AUTH_GOOGLE_OAUTH2_EXTRA_DATA = ["first_name", "last_name"]

AUTH_USER_MODEL = "accounts.UserAccount"
...

I found a bunch of similar questions, but the did not fully explain what is the cause of this error.


Solution

  • The problem you're experiencing is that the state is not persistent when you do redirects. The state is saved in the session under the key f'_state_{self.name}_{state}'. The self.name value is not important. But the state one is. The state value is returned from Google as request arguments when it redirects after your credentials are verified. Usually it is stored in oauth_token arg. This value is compared with the one in the session or cache.

    So basically, you should to keep the session when you are making the calls to the backend. If you are using a browser best way to use incognito mode. If you also want to use the postman, make that the session is copied to a request before make it.