Repository: jupyterhub/jupyterhub

Issue:
examples/service-whoami is broken in 4.1.5
### Bug description

The whoami example seems to be broken:
https://github.com/jupyterhub/jupyterhub/tree/4.1.5/examples/service-whoami (also verified on `main`)

### How to reproduce

1. `cd examples/service-whoami`
2. `jupyterhub --debug`
3. Go to http://localhost:8000/services/whoami-api/ in a private/incognito browser window
4. You should be sent to the login page, login with a dummy account

#### Expected behaviour

You should see the whoami-api response as described in the readme https://github.com/jupyterhub/jupyterhub/tree/4.1.5/examples/service-whoami

#### Actual behaviour

You will see an error, `The page isn’t redirecting properly` (in Firefox)

Hub logs:
```
[I 2024-04-09 17:43:09.987 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:09.988 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 11.68ms
[I 2024-04-09 17:43:10.003 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:10.004 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 11.05ms
[I 2024-04-09 17:43:10.018 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:10.019 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 6.99ms
[I 2024-04-09 17:43:10.030 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:10.031 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 5.90ms
[I 2024-04-09 17:43:10.041 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:10.042 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 5.42ms
[I 2024-04-09 17:43:10.053 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:10.054 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 6.13ms
[I 2024-04-09 17:43:10.064 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:10.064 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 5.50ms
[I 2024-04-09 17:43:10.075 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:10.076 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 5.23ms
[I 2024-04-09 17:43:10.086 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:10.086 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 5.22ms
[I 2024-04-09 17:43:10.093 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'14fbce37f602474bafe7b5a440164f55:da54c4c0630f4a01be7e33a97422dd7c' {'path': '/hub/'}
[I 2024-04-09 17:43:10.094 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fwhoami-api%2F -> /services/whoami-api/ (a@127.0.0.1) 4.39ms
```




Discussion:
We noticed a similar behaviour in our [announcement service](https://gitlab.com/idris-cnrs/jupyter/jupyter-services/jupyterhub-announcement) which is based [jupyterhub-announcement](https://github.com/rcthomas/jupyterhub-announcement ). The main take away is that when we put the `GET` method of announcement under `authenticated` decorator, these redirect loops happen.

This can be verified from the official [service-announcement](https://github.com/jupyterhub/jupyterhub/tree/4.1.5/examples/service-announcement) as well. Simply add `web.authenticated` to [get method here](https://github.com/jupyterhub/jupyterhub/blob/b4053616743eb011e5bb568700f25afca17aef53/examples/service-announcement/announcement.py#L28) and running the example as per instructions will show a bunch of redirects in browser console. Server logs show them as well:

```
[I 2024-04-10 18:44:31.501 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.502 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 1.63ms
[I 2024-04-10 18:44:31.588 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.588 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 1.42ms
[I 2024-04-10 18:44:31.621 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.621 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 1.69ms
[I 2024-04-10 18:44:31.664 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.665 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 5.38ms
[I 2024-04-10 18:44:31.681 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.682 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 2.75ms
[I 2024-04-10 18:44:31.696 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.697 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 3.04ms
[I 2024-04-10 18:44:31.713 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.714 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 2.86ms
[I 2024-04-10 18:44:31.724 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.724 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 1.64ms
[I 2024-04-10 18:44:31.733 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.733 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 1.43ms
[I 2024-04-10 18:44:31.742 JupyterHub _xsrf_utils:125] Setting new xsrf cookie for b'818e4faa1d8443a683ad8e92b98af799:1104f2f2b64e4265b081fadaadfe1099' {'path': '/hub/'}
[I 2024-04-10 18:44:31.743 JupyterHub log:192] 302 GET /hub/login?next=%2Fservices%2Fannouncement%2F -> /services/announcement/ (announcer@127.0.0.1) 1.43ms
```

Resolving patch:
diff --git a/examples/service-whoami/jupyterhub_config.py b/examples/service-whoami/jupyterhub_config.py
--- a/examples/service-whoami/jupyterhub_config.py
+++ b/examples/service-whoami/jupyterhub_config.py
@@ -7,6 +7,7 @@
         'name': 'whoami-api',
         'url': 'http://127.0.0.1:10101',
         'command': [sys.executable, './whoami.py'],
+        'display': False,
     },
     {
         'name': 'whoami-oauth',
@@ -36,3 +37,5 @@
 c.JupyterHub.authenticator_class = 'dummy'
 c.JupyterHub.spawner_class = 'simple'
 c.JupyterHub.ip = '127.0.0.1'  # let's just run on localhost while dummy auth is enabled
+# default to home page, since we don't want to start servers for this demo
+c.JupyterHub.default_url = "/hub/home"
diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py
--- a/jupyterhub/services/auth.py
+++ b/jupyterhub/services/auth.py
@@ -250,7 +250,6 @@ class HubAuth(SingletonConfigurable):
       fetched from JUPYTERHUB_API_URL by default.
     - cookie_cache_max_age: the number of seconds responses
       from the Hub should be cached.
-    - login_url (the *public* ``/hub/login`` URL of the Hub).
     """
 
     hub_host = Unicode(
@@ -331,17 +330,18 @@ def _default_hub_prefix(self):
         return url_path_join(os.getenv('JUPYTERHUB_BASE_URL') or '/', 'hub') + '/'
 
     login_url = Unicode(
-        '/hub/login',
-        help="""The login URL to use
-
-        Typically /hub/login
+        '',
+        help="""The login URL to use, if any.
+        
+        The base HubAuth class doesn't support login via URL,
+        and will raise 403 on `@web.authenticated` requests without a valid token.
+        
+        An empty string here raises 403 errors instead of redirecting.
+        
+        HubOAuth will redirect to /hub/api/oauth2/authorize.
         """,
     ).tag(config=True)
 
-    @default('login_url')
-    def _default_login_url(self):
-        return self.hub_host + url_path_join(self.hub_prefix, 'login')
-
     keyfile = Unicode(
         os.getenv('JUPYTERHUB_SSL_KEYFILE', ''),
         help="""The ssl key to use for requests
@@ -1385,6 +1385,12 @@ def get_login_url(self):
         if self._hub_login_url is not None:
             # cached value, don't call this more than once per handler
             return self._hub_login_url
+
+        if not self.hub_auth.login_url:
+            # HubOAuth is required for login via redirect,
+            # base class can only raise to avoid redirect loops
+            raise HTTPError(403)
+
         # 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


Test patch:
diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py
--- a/jupyterhub/tests/test_services_auth.py
+++ b/jupyterhub/tests/test_services_auth.py
@@ -91,11 +91,7 @@ async def test_hubauth_token(app, mockservice_url, create_user_with_scopes):
         public_url(app, mockservice_url) + '/whoami/?token=%s' % token,
         allow_redirects=False,
     )
-    assert r.status_code == 302
-    assert 'Location' in r.headers
-    location = r.headers['Location']
-    path = urlparse(location).path
-    assert path.endswith('/hub/login')
+    assert r.status_code == 403
 
 
 @pytest.mark.parametrize(
@@ -177,11 +173,7 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
         public_url(app, mockservice_url) + 'whoami/?token=%s' % token,
         allow_redirects=False,
     )
-    assert r.status_code == 302
-    assert 'Location' in r.headers
-    location = r.headers['Location']
-    path = urlparse(location).path
-    assert path.endswith('/hub/login')
+    assert r.status_code == 403
 
 
 @pytest.mark.parametrize(