Repository: cisagov/manage.get.gov

Issue:
Login still resulting in "401" error for some users
### Current Behavior

Some users are still seeing a "401" error when logging in

1. "401" error is displayed when user logs in
2. By clicking sign in again, user is able to successfully log in without error

[Slack discussion](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1708556270444239)

Logs recorded:
![image](https://github.com/cisagov/manage.get.gov/assets/144387994/31dd5ea4-2a0b-48f1-a40f-ee0e649a26f4)


### Expected Behavior

User logs in successfully without encountering error.

### Steps to Reproduce
Users who see this just do the following
1. click "sign in"
2. "401" error is displayed
3. By clicking sign in again, user is able to successfully log in without error

Katherine is the one on the team who sees this with frequency so it would be worth seeing the logs around when her email logs in.
### Environment

Environment: Stable
Browser: Chrome
Login method: username/password combo

### Additional Context
We fixed this issue in relation to it being related to states and we appear to have another bug that we missed with OIDC that is presenting similar behavior to the last one
related to: #1505

### Issue Links



Discussion:
Triaged as Medium due to public-facing. This may cause confusion. some will retry, others will give up and possible create a help-desk ticket. Also, this is an unknown cause, which could get worse, unexpectedly.
Added slack thread per request. Removed refinement tag.

Resolving patch:
diff --git a/src/djangooidc/exceptions.py b/src/djangooidc/exceptions.py
--- a/src/djangooidc/exceptions.py
+++ b/src/djangooidc/exceptions.py
@@ -33,8 +33,8 @@ class AuthenticationFailed(OIDCException):
     friendly_message = "This login attempt didn't work."
 
 
-class NoStateDefined(OIDCException):
-    friendly_message = "The session state is None."
+class StateMismatch(AuthenticationFailed):
+    friendly_message = "State mismatch. This login attempt didn't work."
 
 
 class InternalError(OIDCException):
diff --git a/src/djangooidc/oidc.py b/src/djangooidc/oidc.py
--- a/src/djangooidc/oidc.py
+++ b/src/djangooidc/oidc.py
@@ -182,10 +182,20 @@ def callback(self, unparsed_response, session):
 
         if authn_response["state"] != session.get("state", None):
             # this most likely means the user's Django session vanished
-            logger.error("Received state not the same as expected for %s" % state)
             if session.get("state", None) is None:
-                raise o_e.NoStateDefined()
-            raise o_e.AuthenticationFailed(locator=state)
+                logger.error(
+                    f"The OP state {state} does not match the session state. "
+                    f"The session state is None. "
+                    f"authn_response['state'] = {authn_response['state']} "
+                    f"session.get('state', None) = {session.get('state', None)}"
+                )
+            else:
+                logger.error(
+                    f"The OP state {state} does not match the session state. "
+                    f"authn_response['state'] = {authn_response['state']} "
+                    f"session.get('state', None) = {session.get('state', None)}"
+                )
+            raise o_e.StateMismatch()
 
         if self.behaviour.get("response_type") == "code":
             # need an access token to get user info (and to log the user out later)
diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py
--- a/src/djangooidc/views.py
+++ b/src/djangooidc/views.py
@@ -108,16 +108,25 @@ def login_callback(request):
         if user:
             login(request, user)
             logger.info("Successfully logged in user %s" % user)
-            # Double login bug (1507)?
+            # Clear the flag if the exception is not caught
+            request.session.pop("redirect_attempted", None)
             return redirect(request.session.get("next", "/"))
         else:
             raise o_e.BannedUser()
-    except o_e.NoStateDefined as nsd_err:
-        # In the event that a user is in the middle of a login when the app is restarted,
-        # their session state will no longer be available, so redirect the user to the
-        # beginning of login process without raising an error to the user.
-        logger.warning(f"No State Defined: {nsd_err}")
-        return redirect(request.session.get("next", "/"))
+    except o_e.StateMismatch as nsd_err:
+        # Check if the redirect has already been attempted
+        if not request.session.get("redirect_attempted", False):
+            # Set the flag to indicate that the redirect has been attempted
+            request.session["redirect_attempted"] = True
+
+            # In the event of a state mismatch between OP and session, redirect the user to the
+            # beginning of login process without raising an error to the user. Attempt once.
+            logger.warning(f"No State Defined: {nsd_err}")
+            return redirect(request.session.get("next", "/"))
+        else:
+            # Clear the flag if the exception is not caught
+            request.session.pop("redirect_attempted", None)
+            return error_page(request, nsd_err)
     except Exception as err:
         return error_page(request, err)
 


Test patch:
diff --git a/src/djangooidc/tests/test_views.py b/src/djangooidc/tests/test_views.py
--- a/src/djangooidc/tests/test_views.py
+++ b/src/djangooidc/tests/test_views.py
@@ -4,7 +4,7 @@
 from django.test import Client, TestCase, RequestFactory
 from django.urls import reverse
 
-from djangooidc.exceptions import NoStateDefined, InternalError
+from djangooidc.exceptions import StateMismatch, InternalError
 from ..views import login_callback
 
 from .common import less_console_noise
@@ -129,21 +129,35 @@ def test_openid_initializes_client_and_calls_create_authn_request(self, mock_cli
                     self.assertContains(response, "Hi")
 
     def test_login_callback_with_no_session_state(self, mock_client):
-        """If the local session is None (ie the server restarted while user was logged out),
+        """If the local session does not match the OP session,
         we do not throw an exception. Rather, we attempt to login again."""
         with less_console_noise():
-            # MOCK
-            # mock the acr_value to some string
-            # mock the callback function to raise the NoStateDefined Exception
+            # MOCK get_default_acr_value and the callback to raise StateMismatch
+            # error when called
             mock_client.get_default_acr_value.side_effect = self.create_acr
-            mock_client.callback.side_effect = NoStateDefined()
-            # TEST
-            # test the login callback
+            mock_client.callback.side_effect = StateMismatch()
+            # TEST receiving a response from login.gov
             response = self.client.get(reverse("openid_login_callback"))
-            # ASSERTIONS
-            # assert that the user is redirected to the start of the login process
+            # ASSERT
             self.assertEqual(response.status_code, 302)
             self.assertEqual(response.url, "/")
+            # Check that the redirect_attempted flag is set in the session
+            self.assertTrue(self.client.session.get("redirect_attempted", False))
+
+    def test_login_callback_with_no_session_state_attempt_again_only_once(self, mock_client):
+        """We only attempt to relogin once. After that, it's the error page for you."""
+        with less_console_noise():
+            # MOCK get_default_acr_value, redirect_attempted to True and the callback
+            # to raise StateMismatch error when called
+            mock_client.get_default_acr_value.side_effect = self.create_acr
+            mock_client.callback.side_effect = StateMismatch()
+            session = self.client.session
+            session["redirect_attempted"] = True
+            session.save()
+            # TEST receiving a response from login.gov
+            response = self.client.get(reverse("openid_login_callback"))
+            # ASSERT
+            self.assertEqual(response.status_code, 401)
 
     def test_login_callback_reads_next(self, mock_client):
         """If the next value is set in the session, test that login_callback returns