Repository: jupyterhub/jupyterhub

Issue:
singleuser sets unused oauth state cookie when not redirecting
Attempting to start oauth login too many times fill browser cookies, eventually ending in [431 headers too big](https://discourse.jupyter.org/t/error-431-headers-too-big-during-singleuser-oauth/21833/15).

Background:

- tornado's `@web.authenticated` checks if a user is authenticated, and redirects to `get_login_url()`
- jupyterlab makes lots of API requests, even while not logged in, which can't follow those redirects
- oauth state means that the login URL includes the oauth state, which is also stored in a cookie
- jupyter-server [overrides get_login_url](https://github.com/jupyter-server/jupyter_server/blob/v2.10.0/jupyter_server/base/handlers.py#L755-L762) for API requests to return 403 instead of redirecting
- HubOAuth _also_ [overrides get_login_url](https://github.com/jupyter-server/jupyter_server/blob/v2.10.0/jupyter_server/base/handlers.py#L755-L762) in order to inject the oauth state into the login URL

This combination means that when login url is _computed_, the oauth state cookie is set, even if the request doesn't actually get redirected, [producing logs like this](https://discourse.jupyter.org/t/error-431-headers-too-big-during-singleuser-oauth/21833/14):

```
2023-11-08T13:29:06.511Z | No user identified
-- | --
2023-11-08T13:29:06.511Z | Detected unused OAuth state cookies
2023-11-08T13:29:06.511Z | Redirecting to login url: /hub/api/oauth2/authorize?client_id=jupyterhub-user-[user]&redirect_uri=%2Fuser%2F[user]%2Foauth_callback&response_type=code&state=[state]
2023-11-08T13:29:06.511Z | wrote error: ‘Forbidden’
2023-11-08T13:29:06.511Z | 403 GET /user/[user]/api/contents/notebooks/jump_detection/replay_jump_detection.ipynb?content=0&1699450145824 (@10.49.241.58) 2.80ms
```

What we need to do is avoid setting the oauth state cookie until _after_ we know that it will be redirected to.

Best solution: move the cookie-setting step to inside an overridded `redirect` method, to ensure it's really truly overridden in the event of a redirect. Downside: I don't think I can solve this without breaking a public API in HubAuth (that the cookie is computed only by setting it)

Second-best solution that's doable without any breakage: append `state` to the login URL after computing it with `super()`. I think this should be pretty straightforward.



Discussion:

Resolving patch:
diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py
--- a/jupyterhub/services/auth.py
+++ b/jupyterhub/services/auth.py
@@ -1079,19 +1079,30 @@ def hub_auth(self):
     def hub_auth(self, auth):
         self._hub_auth = auth
 
+    _hub_login_url = None
+
     def get_login_url(self):
         """Return the Hub's login URL"""
-        login_url = self.hub_auth.login_url
+        if self._hub_login_url is not None:
+            # cached value, don't call this more than once per handler
+            return self._hub_login_url
+        # temporary override at setting level,
+        # to allow any subclass overrides of get_login_url to preserve their effect
+        # for example, APIHandler raises 403 to prevent redirects
+        with mock.patch.dict(
+            self.application.settings, {"login_url": self.hub_auth.login_url}
+        ):
+            login_url = super().get_login_url()
+        app_log.debug("Redirecting to login url: %s", login_url)
+
         if isinstance(self.hub_auth, HubOAuth):
             # add state argument to OAuth url
+            # must do this _after_ allowing get_login_url to raise
+            # so we don't set unused cookies
             state = self.hub_auth.set_state_cookie(self, next_url=self.request.uri)
             login_url = url_concat(login_url, {'state': state})
-        # temporary override at setting level,
-        # to allow any subclass overrides of get_login_url to preserve their effect
-        # for example, APIHandler raises 403 to prevent redirects
-        with mock.patch.dict(self.application.settings, {"login_url": login_url}):
-            app_log.debug("Redirecting to login url: %s", login_url)
-            return super().get_login_url()
+        self._hub_login_url = login_url
+        return login_url
 
     def check_hub_user(self, model):
         """Check whether Hub-authenticated user or service should be allowed.
diff --git a/jupyterhub/singleuser/extension.py b/jupyterhub/singleuser/extension.py
--- a/jupyterhub/singleuser/extension.py
+++ b/jupyterhub/singleuser/extension.py
@@ -130,22 +130,30 @@ def _default_hub_auth(self):
     def _patch_get_login_url(self, handler):
         original_get_login_url = handler.get_login_url
 
+        _hub_login_url = None
+
         def get_login_url():
             """Return the Hub's login URL, to begin login redirect"""
-            login_url = self.hub_auth.login_url
-            # add state argument to OAuth url
-            state = self.hub_auth.set_state_cookie(
-                handler, next_url=handler.request.uri
-            )
-            login_url = url_concat(login_url, {'state': state})
-            # temporary override at setting level,
+            nonlocal _hub_login_url
+            if _hub_login_url is not None:
+                # cached value, don't call this more than once per handler
+                return _hub_login_url
+            # temporary override at settings level,
             # to allow any subclass overrides of get_login_url to preserve their effect;
             # for example, APIHandler raises 403 to prevent redirects
             with mock.patch.dict(
-                handler.application.settings, {"login_url": login_url}
+                handler.application.settings, {"login_url": self.hub_auth.login_url}
             ):
-                self.log.debug("Redirecting to login url: %s", login_url)
-                return original_get_login_url()
+                login_url = original_get_login_url()
+            self.log.debug("Redirecting to login url: %s", login_url)
+            # add state argument to OAuth url
+            # must do this _after_ allowing get_login_url to raise
+            # so we don't set unused cookies
+            state = self.hub_auth.set_state_cookie(
+                handler, next_url=handler.request.uri
+            )
+            _hub_login_url = url_concat(login_url, {'state': state})
+            return _hub_login_url
 
         handler.get_login_url = get_login_url
 


Test patch:
diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py
--- a/jupyterhub/tests/test_singleuser.py
+++ b/jupyterhub/tests/test_singleuser.py
@@ -400,3 +400,16 @@ async def test_token_url_cookie(app, user, full_spawn):
     assert r.status_code == 200
 
     await user.stop()
+
+
+async def test_api_403_no_cookie(app, user, full_spawn):
+    """unused oauth cookies don't get set for failed requests to API handlers"""
+    await user.spawn()
+    await app.proxy.add_user(user)
+    url = url_path_join(public_url(app, user), "/api/contents/")
+    r = await async_requests.get(url, allow_redirects=False)
+    # 403, not redirect
+    assert r.status_code == 403
+    # no state cookie set
+    assert not r.cookies
+    await user.stop()