From 6ad6cf01c58b1672536fd333e19187eaf9df323c Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 14 Feb 2023 16:04:56 +0100 Subject: [PATCH 1/8] default to auth extensions with Jupyter Server 2 --- jupyterhub/singleuser/__init__.py | 67 +++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/jupyterhub/singleuser/__init__.py b/jupyterhub/singleuser/__init__.py index eb46d258..9c1c8269 100644 --- a/jupyterhub/singleuser/__init__.py +++ b/jupyterhub/singleuser/__init__.py @@ -1,40 +1,75 @@ """JupyterHub single-user server entrypoints Contains default notebook-app subclass and mixins + +Defaults to: + +- Jupyter server extension with Jupyter Server >=2 +- Subclass with Jupyter Server <2 or clasic notebook + +Application subclass can be controlled with environment variables: + +- JUPYTERHUB_SINGLEUSER_EXTENSION=1 to opt-in to the extension (requires Jupyter Server 2) +- JUPYTERHUB_SINGLEUSER_APP=notebook (or jupyter-server) to opt-in """ import os from .mixins import HubAuthenticatedHandler, make_singleuser_app -if os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION", "") not in ("", "0"): +_as_extension = False +_extension_env = os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION", "") +_app_env = os.environ.get("JUPYTERHUB_SINGLEUSER_APP", "") + +if not _extension_env: + # extension env not set, check app env + if not _app_env or 'jupyter_server' in _app_env.replace("-", "_"): + # no app env set or using jupyter-server, this is the default branch + # default behavior: + # - extension, if jupyter server 2 + # - older subclass app, otherwise + try: + import jupyter_server + + _server_major = int(jupyter_server.__version__.split(".", 1)[0]) + except Exception: + # don't have jupyter-server, assume classic notebook + _as_extension = False + else: + # default to extension if jupyter-server >=2 + _as_extension = _server_major >= 2 + + elif _app_env == "extension": + _as_extension = True + else: + # app env set and not to jupyter-server, that opts out of extension + _as_extension = False +elif _extension_env == "0": + _as_extension = False +else: + # extension env set to anything non-empty other than '0' enables the extension _as_extension = True + +if _as_extension: # check for conflict in singleuser entrypoint environment variables - if os.environ.get("JUPYTERHUB_SINGLEUSER_APP", "") not in { + if _app_env not in { "", "jupyter_server", "jupyter-server", "extension", "jupyter_server.serverapp.ServerApp", }: - ext = os.environ["JUPYTERHUB_SINGLEUSER_EXTENSION"] - app = os.environ["JUPYTERHUB_SINGLEUSER_APP"] raise ValueError( - f"Cannot use JUPYTERHUB_SINGLEUSER_EXTENSION={ext} with JUPYTERHUB_SINGLEUSER_APP={app}." + f"Cannot use JUPYTERHUB_SINGLEUSER_EXTENSION={_extension_env} with JUPYTERHUB_SINGLEUSER_APP={_app_env}." " Please pick one or the other." ) from .extension import main else: - _as_extension = False - try: - from .app import SingleUserNotebookApp, main - except ImportError: - # check for Jupyter Server 2.0 ? - from .extension import main - else: - # backward-compatibility - JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class - JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class - OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class + from .app import SingleUserNotebookApp, main + + # backward-compatibility + JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class + JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class + OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class __all__ = [ From 09a595851ed93bcdbc17c897d4b94f84e17f74f2 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 14 Feb 2023 16:59:26 +0100 Subject: [PATCH 2/8] add test matrix entry for jupyter-server 1.x --- .github/workflows/test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f60e891..645c714d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,7 +79,7 @@ jobs: oldest_dependencies: oldest_dependencies legacy_notebook: legacy_notebook - python: "3.8" - legacy_notebook: legacy_notebook + jupyter_server: "1.*" - python: "3.9" db: mysql - python: "3.10" @@ -161,6 +161,9 @@ jobs: pip uninstall jupyter_server --yes pip install 'notebook<7' fi + if [ "${{ matrix.jupyter_server }}" != "" ]; then + pip install "jupyter_server==${{ matrix.jupyter_server }}" + fi if [ "${{ matrix.db }}" == "mysql" ]; then pip install mysql-connector-python fi From 0ea813e6ada9a7c6e85d305ce6f7d914bddd5a82 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 15 Feb 2023 15:08:39 +0100 Subject: [PATCH 3/8] enable allow_remote access in singleuser extension otherwise Host header validation prevents remote access --- jupyterhub/singleuser/extension.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jupyterhub/singleuser/extension.py b/jupyterhub/singleuser/extension.py index 341df997..d9d9f55d 100644 --- a/jupyterhub/singleuser/extension.py +++ b/jupyterhub/singleuser/extension.py @@ -475,6 +475,7 @@ class JupyterHubSingleUser(ExtensionApp): cfg.identity_provider_class = JupyterHubIdentityProvider # disable some single-user features + cfg.allow_remote_access = True cfg.open_browser = False cfg.trust_xheaders = True cfg.quit_button = False From 4c1df3f3fe55d339283d448678823ce03b81415e Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 16 Feb 2023 13:42:15 +0100 Subject: [PATCH 4/8] fix hub_host links with subdomains - fixes missing hub_host in singleuser mixins - fixes test to match extension behavior, which is correct --- jupyterhub/singleuser/mixins.py | 1 + jupyterhub/tests/test_singleuser.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py index e4854906..723ff961 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -660,6 +660,7 @@ class SingleUserNotebookAppMixin(Configurable): certfile=self.certfile, client_ca=self.client_ca, ) + self.hub_host = self.hub_auth.hub_host # smoke check if not self.hub_auth.oauth_client_id: raise ValueError("Missing OAuth client ID") diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py index 6cc6e655..da8356a2 100644 --- a/jupyterhub/tests/test_singleuser.py +++ b/jupyterhub/tests/test_singleuser.py @@ -283,4 +283,8 @@ async def test_nbclassic_control_panel(app, user, full_spawn): page = BeautifulSoup(r.text, "html.parser") link = page.find("a", id="jupyterhub-control-panel-link") assert link, f"Missing jupyterhub-control-panel-link in {page}" - assert link["href"] == url_path_join(app.base_url, "hub/home") + if app.subdomain_host: + prefix = public_url(app) + else: + prefix = app.base_url + assert link["href"] == url_path_join(prefix, "hub/home") From 18adfbbf30def2ec848e63a2f836c3c35386ceef Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 16 Feb 2023 13:58:34 +0100 Subject: [PATCH 5/8] add internal-ssl config for singleuser extension --- jupyterhub/singleuser/extension.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/jupyterhub/singleuser/extension.py b/jupyterhub/singleuser/extension.py index d9d9f55d..c1eafffc 100644 --- a/jupyterhub/singleuser/extension.py +++ b/jupyterhub/singleuser/extension.py @@ -502,6 +502,13 @@ class JupyterHubSingleUser(ExtensionApp): # to make sure it has the desired effect cfg.default_url = self.default_url = self.get_default_url() + # load SSL configuration + cfg.keyfile = os.environ.get('JUPYTERHUB_SSL_KEYFILE') or '' + cfg.certfile = os.environ.get('JUPYTERHUB_SSL_CERTFILE') or '' + cfg.client_ca = os.environ.get('JUPYTERHUB_SSL_CLIENT_CA') or '' + if cfg.certfile: + self.serverapp.log.info(f"Using SSL cert {cfg.certfile}") + # Jupyter Server default: config files have higher priority than extensions, # by: # 1. load config files From e9fd6e1c3275a3733ab3818437a27e55999e36d3 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 16 Feb 2023 14:26:05 +0100 Subject: [PATCH 6/8] make sure ssl/subdomain are covered both for both mixin and serverextension --- .github/workflows/test.yml | 12 ++++++++++-- jupyterhub/singleuser/extension.py | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 645c714d..73602d12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,14 +86,20 @@ jobs: db: postgres - python: "3.11" subdomain: subdomain + serverextension: serverextension - python: "3.11" ssl: ssl + serverextension: serverextension + - python: "3.11" + subdomain: subdomain + noextension: noextension + - python: "3.11" + ssl: ssl + noextension: noextension - python: "3.11" selenium: selenium - python: "3.11" main_dependencies: main_dependencies - - python: "3.10" - serverextension: serverextension steps: # NOTE: In GitHub workflows, environment variables are set by writing @@ -119,6 +125,8 @@ jobs: fi if [ "${{ matrix.serverextension }}" != "" ]; then echo "JUPYTERHUB_SINGLEUSER_EXTENSION=1" >> $GITHUB_ENV + elif [ "${{ matrix.noextension }}" != "" ]; then + echo "JUPYTERHUB_SINGLEUSER_EXTENSION=0" >> $GITHUB_ENV fi - uses: actions/checkout@v3 # NOTE: actions/setup-node@v3 make use of a cache within the GitHub base diff --git a/jupyterhub/singleuser/extension.py b/jupyterhub/singleuser/extension.py index c1eafffc..fd2dc611 100644 --- a/jupyterhub/singleuser/extension.py +++ b/jupyterhub/singleuser/extension.py @@ -502,7 +502,7 @@ class JupyterHubSingleUser(ExtensionApp): # to make sure it has the desired effect cfg.default_url = self.default_url = self.get_default_url() - # load SSL configuration + # load internal SSL configuration cfg.keyfile = os.environ.get('JUPYTERHUB_SSL_KEYFILE') or '' cfg.certfile = os.environ.get('JUPYTERHUB_SSL_CERTFILE') or '' cfg.client_ca = os.environ.get('JUPYTERHUB_SSL_CLIENT_CA') or '' From 403b5f1ffe849b0c3464febfd6617146e9d61276 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 16 Feb 2023 14:35:54 +0100 Subject: [PATCH 7/8] subset tests for singleuser cases saves some time for some matrix entries --- .github/workflows/test.yml | 6 ++++-- pytest.ini | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 73602d12..fc059ca7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,6 @@ on: env: # UTF-8 content may be interpreted as ascii and causes errors without this. LANG: C.UTF-8 - PYTEST_ADDOPTS: "--verbose --color=yes" SQLALCHEMY_WARN_20: "1" permissions: @@ -80,6 +79,7 @@ jobs: legacy_notebook: legacy_notebook - python: "3.8" jupyter_server: "1.*" + subset: singleuser - python: "3.9" db: mysql - python: "3.10" @@ -93,9 +93,11 @@ jobs: - python: "3.11" subdomain: subdomain noextension: noextension + subset: singleuser - python: "3.11" ssl: ssl noextension: noextension + subset: singleuser - python: "3.11" selenium: selenium - python: "3.11" @@ -231,7 +233,7 @@ jobs: - name: Run pytest run: | - pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests + pytest -k "${{ matrix.subset }}" --maxfail=2 --cov=jupyterhub jupyterhub/tests - uses: codecov/codecov-action@v3 diff --git a/pytest.ini b/pytest.ini index b594142e..c7e42707 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,7 +7,7 @@ asyncio_mode = auto # jupyter_server plugin is incompatible with notebook imports -addopts = -p no:jupyter_server -m 'not selenium' +addopts = -p no:jupyter_server -m 'not selenium' --color yes --durations 10 --verbose python_files = test_*.py markers = From f1075b5a21b2c083db3ff5bba62df21f13475ccf Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 16 Feb 2023 15:07:20 +0100 Subject: [PATCH 8/8] avoid error when browsers send invalid cookies --- jupyterhub/log.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/jupyterhub/log.py b/jupyterhub/log.py index 46574e65..4e5dd361 100644 --- a/jupyterhub/log.py +++ b/jupyterhub/log.py @@ -105,11 +105,16 @@ def _scrub_headers(headers): auth_type = '' headers['Authorization'] = f'{auth_type} [secret]' if 'Cookie' in headers: - c = SimpleCookie(headers['Cookie']) - redacted = [] - for name in c.keys(): - redacted.append(f"{name}=[secret]") - headers['Cookie'] = '; '.join(redacted) + try: + c = SimpleCookie(headers['Cookie']) + except Exception as e: + # it's possible for browsers to send invalid cookies + headers['Cookie'] = f"Invalid Cookie: {e}" + else: + redacted = [] + for name in c.keys(): + redacted.append(f"{name}=[secret]") + headers['Cookie'] = '; '.join(redacted) return headers