From 88189d54d9187db35ab49839a116e192ee252818 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Wed, 10 Apr 2024 10:34:28 +0100 Subject: [PATCH 1/2] Add a test for `allow_unauthenticated_access` (xfail) --- jupyterhub/tests/test_singleuser.py | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py index f5b16763..d2d4be17 100644 --- 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( From aefc8de49a77c92e7704ba57fba3e3a01af5fe21 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:03:33 +0100 Subject: [PATCH 2/2] Add @allow_unauthenticated decorators --- jupyterhub/singleuser/_decorator.py | 14 ++++++++++++++ jupyterhub/singleuser/extension.py | 6 ++++++ jupyterhub/singleuser/mixins.py | 6 ++++++ 3 files changed, 26 insertions(+) create mode 100644 jupyterhub/singleuser/_decorator.py diff --git a/jupyterhub/singleuser/_decorator.py b/jupyterhub/singleuser/_decorator.py new file mode 100644 index 00000000..71f8c89f --- /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 index dc9e5f36..e34e05fc 100644 --- a/jupyterhub/singleuser/extension.py +++ b/jupyterhub/singleuser/extension.py @@ -51,6 +51,7 @@ from jupyterhub.utils import ( 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 index c08fda9d..8496a6e0 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -52,6 +52,7 @@ from ..utils import ( 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 @@ class JupyterHubLoginHandlerMixin: 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 = {