Repository: jupyterhub/jupyterhub

Issue:
redirect loop on invalid single-user auth token
when the single-user server's API request fails with 403, it's handled as failed login (302) causing a redirect loop, when it should be handled as "500: oh noes, I can't do anything!"



Discussion:
Oooooh...I've definitely hit this before without realizing it.  Makes sense now.

This one happens when restarting/resetting the Hub, invalidating the API token used to check cookies. If that API token is invalidated, the single-user server is defunct and has to be restarted.

It's not the _only_ cause of redirect loops - they seem to be the symptom of almost any misbehavior in single-user login, but it's at least one I can reproduce and explain.

Yeah, I think I've hit this with Dockerspawner with the following sequence:
1. Start hub with dockerspawner.
2. Log in and spawn a single user server.
3. Shut down the hub, allowing it to stop (but not kill) any running containers.
4. Restart hub and login as same user as before.  Dockerspawner will restart the exited container rather than create a new one, which then triggers a redirect loop because the tokens are out of sync.

It's possible that there's a step or two I'm missing in there.

Yup, that's exactly right. The same sequence _should_ be fine if the Hub state database persists from one Hub session to the next.


Resolving patch:
diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py
--- a/jupyterhub/singleuser.py
+++ b/jupyterhub/singleuser.py
@@ -9,6 +9,7 @@
 import requests
 
 from tornado import ioloop
+from tornado.web import HTTPError
 
 from IPython.utils.traitlets import Unicode
 
@@ -35,7 +36,7 @@ def login_available(settings):
     
     @staticmethod
     def verify_token(self, cookie_name, encrypted_cookie):
-        """monkeypatch method for token verification"""
+        """method for token verification"""
         cookie_cache = self.settings['cookie_cache']
         if encrypted_cookie in cookie_cache:
             # we've seen this token before, don't ask upstream again
@@ -51,9 +52,15 @@ def verify_token(self, cookie_name, encrypted_cookie):
         )
         if r.status_code == 404:
             data = {'user' : ''}
+        if r.status_code == 403:
+            self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason)
+            raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted")
+        elif r.status_code >= 500:
+            self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason)
+            raise HTTPError(502, "Failed to check authorization (upstream problem)")
         elif r.status_code >= 400:
             self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason)
-            data = None
+            raise HTTPError(500, "Failed to check authorization")
         else:
             data = r.json()
         cookie_cache[encrypted_cookie] = data
@@ -62,6 +69,13 @@ def verify_token(self, cookie_name, encrypted_cookie):
     @staticmethod
     def get_user(self):
         """alternative get_current_user to query the central server"""
+        # only allow this to be called once per handler
+        # avoids issues if an error is raised,
+        # since this may be called again when trying to render the error page
+        if hasattr(self, '_cached_user'):
+            return self._cached_user
+        
+        self._cached_user = None
         my_user = self.settings['user']
         encrypted_cookie = self.get_cookie(self.cookie_name)
         if encrypted_cookie:
@@ -71,6 +85,7 @@ def get_user(self):
                 return None
             user = auth_data['user']
             if user == my_user:
+                self._cached_user = user
                 return user
             else:
                 return None


Test patch: