Repository: jupyterhub/jupyterhub

Issue:
Broken login flow with user subdomains and external proxy on 5.0.0b1
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Bug description

Under the following conditions:

- version 5.0.0b1. In particular, after the XSS fix and in particular the change to no longer set domain on the hub's login cookie. (I suspect all 4.1.x versions are similarly affected, though I have not verified. we're on the main branch for reasons)
- have enabled per-user subdomains
- be using an external proxy, in particular an HA one like traefik + redis, which inherently has consistency delays, which the "redirect loop" mechanism is specifically designed to accommodate.

In the event that the route is still not setup, the hub will handle the request:

https://user-subdomain.hubdomain/user/user-name/lab 

This is handled by the `PrefixRedirectHandler` handler class. This route is unauthenticated. It will redirect to:

[/hub/user/user-name/lab](/hub/user/user-name/lab)

which of course, resolves to the full URL **on the subdomain**:

http://user-subdomain.hubdomain/hub/user/user-name/lab

which is handled by the `UserUrlRedirect` handler class. This route is authenticated.

#### Previous Behavior

Previously, as the login cookie had domain set, the browser would still send the login cookie even when interacting with the hub via the subdomain, and so the redirect flow would work fine. Eventually, the redirect loop would cease when the routes reached consistency, and the end-user will settle on their singleuser server.

I've verified this on an older revision on the main branch.

#### Observed Behavior

Since the change to the domain property of the cookie, the user is now redirected to the **login page** - on the subdomain in fact - because the login cookie is not sent by the browser to the hub, as the browser is navigating to the hub via the subdomain, so the hub doesn't recognize the session as being logged in. The end-user never lands on their singleuser server.

<!-- Use this section to clearly and concisely describe the bug. -->

### How to reproduce

Without actually going and manually disabling a route, I found that comparing the behavior of

http://user-subdomain.hubdomain/hub/user/user-name/lab

before and after the change to the domain property of the cookie is sufficient to demonstrate the issue. I imagine this technique would work with e.g. the z2jh CSP with subdomains enabled (though of course in a CSP-based setup, with the single-node proxy and synchronous api, I doubt this issue would manifest)

<!-- Use this section to describe the steps that a user would take to experience this bug. -->



Discussion:
Thank you for opening your first issue in this project! Engagement like this is essential for open source projects! :hugs:
<br>If you haven't done so already, check out [Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md). Also, please try to follow the issue template as it helps other other community members to contribute more effectively.
![welcome](https://raw.githubusercontent.com/jupyterhub/.github/master/images/welcome.jpg)
You can meet the other [Jovyans](https://jupyter.readthedocs.io/en/latest/community/content-community.html?highlight=jovyan#what-is-a-jovyan) by joining our [Discourse forum](http://discourse.jupyter.org/). There is also an intro thread there where you can stop by and say Hi! :wave:
<br>Welcome to the Jupyter community! :tada:

I have a low-confidence suggestion to fix: when subdomains are enabled, the `PrefixRedirectHandler` could (and maybe only for `/user`) strip the subdomain, or otherwise hard-code the host of the hub domain in the redirect, rather than the current behavior which is to redirect with a relative location `/hub/user`.

This way,

https://user-subdomain.hubdomain/user/user-name/lab

would redirect to

https://hubdomain/hub/user/user-name/lab

in which case, things should work as expected.
I'll try to do some testing, but I think you're right that it should explicitly redirect to the hub domain when domains are enabled.

Resolving patch:
diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py
--- a/jupyterhub/handlers/base.py
+++ b/jupyterhub/handlers/base.py
@@ -1537,6 +1537,16 @@ class PrefixRedirectHandler(BaseHandler):
     """Redirect anything outside a prefix inside.
 
     Redirects /foo to /prefix/foo, etc.
+
+    Redirect specifies hub domain when public_url or subdomains are enabled.
+
+    Mainly handles requests for non-running servers, e.g. to
+
+    /user/tree/ -> /hub/user/tree/
+
+    UserUrlHandler will handle the request after redirect.
+    Don't do anything but redirect here because cookies, etc. won't be available to this request,
+    due to not being on the hub's path or possibly domain.
     """
 
     def get(self):
@@ -1554,7 +1564,19 @@ def get(self):
             # default / -> /hub/ redirect
             # avoiding extra hop through /hub
             path = '/'
-        self.redirect(url_path_join(self.hub.base_url, path), permanent=False)
+
+        redirect_url = redirect_path = url_path_join(self.hub.base_url, path)
+
+        # when using subdomains,
+        # make sure we redirect `user.domain/user/foo` -> `hub.domain/hub/user/foo/...`
+        # so that the Hub handles it properly with cookies and all
+        public_url = self.settings.get("public_url")
+        subdomain_host = self.settings.get("subdomain_host")
+        if public_url:
+            redirect_url = urlunparse(public_url._replace(path=redirect_path))
+        elif subdomain_host:
+            redirect_url = url_path_join(subdomain_host, redirect_path)
+        self.redirect(redirect_url, permanent=False)
 
 
 class UserUrlHandler(BaseHandler):


Test patch:
diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py
--- a/jupyterhub/tests/browser/test_browser.py
+++ b/jupyterhub/tests/browser/test_browser.py
@@ -32,6 +32,20 @@ async def login(browser, username, password=None):
     await browser.get_by_role("button", name="Sign in").click()
 
 
+async def login_home(browser, app, username):
+    """Visit login page, login, go home
+
+    A good way to start a session
+    """
+    login_url = url_concat(
+        url_path_join(public_url(app), "hub/login"),
+        {"next": ujoin(app.hub.base_url, "home")},
+    )
+    await browser.goto(login_url)
+    async with browser.expect_navigation(url=re.compile(".*/hub/home")):
+        await login(browser, username)
+
+
 async def test_open_login_page(app, browser):
     login_url = url_path_join(public_host(app), app.hub.base_url, "login")
     await browser.goto(login_url)
@@ -1367,6 +1381,17 @@ async def test_login_xsrf_initial_cookies(app, browser, case, username):
     await login(browser, username, username)
 
 
+async def test_prefix_redirect_not_running(browser, app, user):
+    # tests PrefixRedirectHandler for stopped servers
+    await login_home(browser, app, user.name)
+    # visit user url (includes subdomain, if enabled)
+    url = public_url(app, user, "/tree/")
+    await browser.goto(url)
+    # make sure we end up on the Hub (domain included)
+    expected_url = url_path_join(public_url(app), f"hub/user/{user.name}/tree/")
+    await expect(browser).to_have_url(expected_url)
+
+
 def _cookie_dict(cookie_list):
     """Convert list of cookies to dict of the form