Repository: jupyterhub/jupyterhub

Issue:
Support `ServerApp.allow_unauthenticated_access = False`
With `c.ServerApp.allow_unauthenticated_access = False` I see:

```
│ Exception: Core endpoints without @allow_unauthenticated, @ws_authenticated, nor @web.authenticated:
│ - GET of JupyterHubLogoutHandler registered for /user/myname/logout                                                                                                                                                                                                                                                                                                                                                                      
│ - GET of JupyterHubOAuthCallbackHandler registered for /user/myname/oauth_callback
```

Both endpoints are likely safe to mark as `@allow_unauthenticated` (I am less clear on `logout` one).


Discussion:

Resolving patch:
diff --git a/jupyterhub/singleuser/_decorator.py b/jupyterhub/singleuser/_decorator.py
new file mode 100644
--- /dev/null
+++ b/jupyterhub/singleuser/_decorator.py
@@ -0,0 +1,14 @@
+from typing import Any, Callable, TypeVar
+
+try:
+    from jupyter_server.auth.decorator import allow_unauthenticated
+except ImportError:
+    FuncT = TypeVar("FuncT", bound=Callable[..., Any])
+
+    # if using an older jupyter-server version this can be a no-op,
+    # as these do not support marking endpoints anyways
+    def allow_unauthenticated(method: FuncT) -> FuncT:
+        return method
+
+
+__all__ = ["allow_unauthenticated"]
diff --git a/jupyterhub/singleuser/extension.py b/jupyterhub/singleuser/extension.py
--- a/jupyterhub/singleuser/extension.py
+++ b/jupyterhub/singleuser/extension.py
@@ -51,6 +51,7 @@
     url_path_join,
 )
 
+from ._decorator import allow_unauthenticated
 from ._disable_user_config import _disable_user_config
 
 SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates"))
@@ -68,6 +69,7 @@ def _exclude_home(path_list):
 
 
 class JupyterHubLogoutHandler(LogoutHandler):
+    @allow_unauthenticated
     def get(self):
         hub_auth = self.identity_provider.hub_auth
         # clear token stored in single-user cookie (set by hub_auth)
@@ -95,6 +97,10 @@ class JupyterHubOAuthCallbackHandler(HubOAuthCallbackHandler):
     def initialize(self, hub_auth):
         self.hub_auth = hub_auth
 
+    @allow_unauthenticated
+    async def get(self):
+        return await super().get()
+
 
 class JupyterHubIdentityProvider(IdentityProvider):
     """Identity Provider for JupyterHub OAuth
diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py
--- a/jupyterhub/singleuser/mixins.py
+++ b/jupyterhub/singleuser/mixins.py
@@ -52,6 +52,7 @@
     make_ssl_context,
     url_path_join,
 )
+from ._decorator import allow_unauthenticated
 from ._disable_user_config import _disable_user_config, _exclude_home
 
 # Authenticate requests with the Hub
@@ -132,6 +133,7 @@ def validate_security(cls, app, ssl_options=None):
 
 
 class JupyterHubLogoutHandlerMixin:
+    @allow_unauthenticated
     def get(self):
         self.settings['hub_auth'].clear_cookie(self)
         self.redirect(
@@ -147,6 +149,10 @@ class OAuthCallbackHandlerMixin(HubOAuthCallbackHandler):
     def hub_auth(self):
         return self.settings['hub_auth']
 
+    @allow_unauthenticated
+    async def get(self):
+        return await super().get()
+
 
 # register new hub related command-line aliases
 aliases = {


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
@@ -2,6 +2,7 @@
 
 import os
 import sys
+import warnings
 from contextlib import nullcontext
 from pathlib import Path
 from pprint import pprint
@@ -291,6 +292,57 @@ async def test_notebook_dir(
         raise ValueError(f"No contents check for {notebook_dir=}")
 
 
+@pytest.mark.parametrize("extension", [True, False])
+@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no auth configuration")
+async def test_forbid_unauthenticated_access(
+    request, app, tmp_path, user, full_spawn, extension
+):
+    try:
+        from jupyter_server.auth.decorator import allow_unauthenticated  # noqa
+    except ImportError:
+        pytest.skip("needs jupyter-server 2.13")
+
+    from jupyter_server.utils import JupyterServerAuthWarning
+
+    # login, start the server
+    cookies = await app.login_user('nandy')
+    s = AsyncSession()
+    s.cookies = cookies
+    user = app.users['nandy']
+    # stop spawner, if running:
+    if user.running:
+        await user.stop()
+    # start with new config:
+    user.spawner.default_url = "/jupyterhub-test-info"
+
+    if extension:
+        user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "1"
+    else:
+        user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "0"
+
+    # make sure it's resolved to start
+    tmp_path = tmp_path.resolve()
+    real_home_dir = tmp_path / "realhome"
+    real_home_dir.mkdir()
+    # make symlink to test resolution
+    home_dir = tmp_path / "home"
+    home_dir.symlink_to(real_home_dir)
+    # home_dir is defined on SimpleSpawner
+    user.spawner.home_dir = str(home_dir)
+    jupyter_config_dir = home_dir / ".jupyter"
+    jupyter_config_dir.mkdir()
+    # verify config paths
+    with (jupyter_config_dir / "jupyter_server_config.py").open("w") as f:
+        f.write("c.ServerApp.allow_unauthenticated_access = False")
+
+    # If there are core endpoints (added by jupyterhub) without decorators,
+    # spawn will error out. If there are extension endpoints without decorators
+    # these will be logged as warnings.
+    with warnings.catch_warnings():
+        warnings.simplefilter("error", JupyterServerAuthWarning)
+        await user.spawn()
+
+
 @pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no --help-all")
 def test_help_output():
     out = check_output(