diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fa154ec96c..21ff9d7b50 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -91,6 +91,8 @@ jobs:
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
@@ -114,8 +116,8 @@ jobs:
echo "PGPASSWORD=hub[test/:?" >> $GITHUB_ENV
echo "JUPYTERHUB_TEST_DB_URL=postgresql://test_user:hub%5Btest%2F%3A%3F@127.0.0.1:5432/jupyterhub" >> $GITHUB_ENV
fi
- if [ "${{ matrix.jupyter_server }}" != "" ]; then
- echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
+ if [ "${{ matrix.serverextension }}" != "" ]; then
+ echo "JUPYTERHUB_SINGLEUSER_EXTENSION=1" >> $GITHUB_ENV
fi
- uses: actions/checkout@v3
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
@@ -163,6 +165,9 @@ jobs:
if [ "${{ matrix.db }}" == "postgres" ]; then
pip install psycopg2-binary
fi
+ if [ "${{ matrix.serverextension }}" != "" ]; then
+ pip install 'jupyter-server>=2'
+ fi
pip freeze
diff --git a/jupyterhub/__init__.py b/jupyterhub/__init__.py
index 4b758c5d9c..3e8cbb17a9 100644
--- a/jupyterhub/__init__.py
+++ b/jupyterhub/__init__.py
@@ -1,3 +1,10 @@
from ._version import __version__, version_info
+
+def _jupyter_server_extension_points():
+ from .singleuser.extension import JupyterHubSingleUser
+
+ return [{"module": "jupyterhub", "app": JupyterHubSingleUser}]
+
+
__all__ = ["__version__", "version_info"]
diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py
index 24106ee25f..c1cdd73428 100644
--- a/jupyterhub/services/auth.py
+++ b/jupyterhub/services/auth.py
@@ -243,7 +243,6 @@ def _api_url(self):
return 'http://127.0.0.1:8081' + url_path_join(self.hub_prefix, 'api')
api_token = Unicode(
- os.getenv('JUPYTERHUB_API_TOKEN', ''),
help="""API key for accessing Hub API.
Default: $JUPYTERHUB_API_TOKEN
@@ -253,6 +252,10 @@ def _api_url(self):
""",
).tag(config=True)
+ @default("api_token")
+ def _default_api_token(self):
+ return os.getenv('JUPYTERHUB_API_TOKEN', '')
+
hub_prefix = Unicode(
'/hub/',
help="""The URL prefix for the Hub itself.
diff --git a/jupyterhub/singleuser/__init__.py b/jupyterhub/singleuser/__init__.py
index f3188bc7b9..93e3d89af5 100644
--- a/jupyterhub/singleuser/__init__.py
+++ b/jupyterhub/singleuser/__init__.py
@@ -2,17 +2,44 @@
Contains default notebook-app subclass and mixins
"""
-from .app import SingleUserNotebookApp, main
+import os
+
from .mixins import HubAuthenticatedHandler, make_singleuser_app
+if os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION", "") not in ("", "0"):
+ # check for conflict in singleuser entrypoint environment variables
+ if os.environ.get("JUPYTERHUB_SINGLEUSER_APP", "") 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=1 with JUPYTERHUB_SINGLEUSER_APP={app}."
+ " Please pick one or the other."
+ )
+ _as_extension = True
+ 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
+
+
__all__ = [
"SingleUserNotebookApp",
"main",
"HubAuthenticatedHandler",
"make_singleuser_app",
]
-
-# backward-compatibility
-JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
-JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
-OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
diff --git a/jupyterhub/singleuser/__main__.py b/jupyterhub/singleuser/__main__.py
index 18d6d1b467..c7c70d0be2 100644
--- a/jupyterhub/singleuser/__main__.py
+++ b/jupyterhub/singleuser/__main__.py
@@ -1,4 +1,4 @@
-from .app import main
+from . import main
if __name__ == '__main__':
main()
diff --git a/jupyterhub/singleuser/app.py b/jupyterhub/singleuser/app.py
index 572c6776f5..e9f6f871a3 100644
--- a/jupyterhub/singleuser/app.py
+++ b/jupyterhub/singleuser/app.py
@@ -16,6 +16,17 @@
JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP")
+# allow shortcut references
+_app_shortcuts = {
+ "notebook": "notebook.notebookapp.NotebookApp",
+ "jupyter_server": "jupyter_server.serverapp.ServerApp",
+ "jupyter-server": "jupyter_server.serverapp.ServerApp",
+ "extension": "jupyter_server.serverapp.ServerApp",
+}
+
+JUPYTERHUB_SINGLEUSER_APP = _app_shortcuts.get(
+ JUPYTERHUB_SINGLEUSER_APP, JUPYTERHUB_SINGLEUSER_APP
+)
if JUPYTERHUB_SINGLEUSER_APP:
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
diff --git a/jupyterhub/singleuser/extension.py b/jupyterhub/singleuser/extension.py
new file mode 100644
index 0000000000..429b1adaca
--- /dev/null
+++ b/jupyterhub/singleuser/extension.py
@@ -0,0 +1,643 @@
+"""
+Integrate JupyterHub auth with Jupyter Server as an Extension
+
+Requires Jupyter Server 2.0, which in turn requires Python 3.7
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import random
+from datetime import timezone
+from functools import wraps
+from pathlib import Path
+from unittest import mock
+from urllib.parse import urlparse
+
+from jupyter_core import paths
+from jupyter_server.auth import Authorizer, IdentityProvider, User
+from jupyter_server.auth.logout import LogoutHandler
+from jupyter_server.extension.application import ExtensionApp
+from tornado.httpclient import AsyncHTTPClient, HTTPRequest
+from tornado.httputil import url_concat
+from tornado.web import HTTPError
+from traitlets import Any, Bool, Instance, Integer, Unicode, default
+
+from jupyterhub._version import __version__, _check_version
+from jupyterhub.services.auth import HubOAuth, HubOAuthCallbackHandler
+from jupyterhub.utils import (
+ exponential_backoff,
+ isoformat,
+ make_ssl_context,
+ url_path_join,
+)
+
+SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates"))
+
+
+def _bool_env(key):
+ """Cast an environment variable to bool
+
+ 0, empty, or unset is False; All other values are True.
+ """
+ if os.environ.get(key, "") in {"", "0"}:
+ return False
+ else:
+ return True
+
+
+def _exclude_home(path_list):
+ """Filter out any entries in a path list that are in my home directory.
+
+ Used to disable per-user configuration.
+ """
+ home = os.path.expanduser('~/')
+ for p in path_list:
+ if not p.startswith(home):
+ yield p
+
+
+class JupyterHubLogoutHandler(LogoutHandler):
+ def get(self):
+ hub_auth = self.identity_provider.hub_auth
+ # clear single-user cookie
+ hub_auth.clear_cookie(self)
+ # redirect to hub to clear the rest
+ self.redirect(hub_auth.hub_host + url_path_join(hub_auth.hub_prefix, "logout"))
+
+
+class JupyterHubUser(User):
+ """Subclass jupyter_server User to store JupyterHub user info"""
+
+ # not dataclass fields,
+ # so these aren't returned in the identity model via the REST API.
+ # The could be, though!
+ hub_user: dict
+
+ def __init__(self, hub_user):
+ self.hub_user = hub_user
+ super().__init__(username=self.hub_user["name"])
+
+
+class JupyterHubOAuthCallbackHandler(HubOAuthCallbackHandler):
+ """Callback handler for completing OAuth with JupyterHub"""
+
+ def initialize(self, hub_auth):
+ self.hub_auth = hub_auth
+
+
+class JupyterHubIdentityProvider(IdentityProvider):
+ """Identity Provider for JupyterHub OAuth
+
+ Replacement for JupyterHub's HubAuthenticated mixin
+ """
+
+ logout_handler_class = JupyterHubLogoutHandler
+
+ hub_auth = Instance(HubOAuth)
+
+ @property
+ def token(self):
+ return self.hub_auth.api_token
+
+ token_generated = False
+
+ @default("hub_auth")
+ def _default_hub_auth(self):
+ # HubAuth gets most of its config from the environment
+ return HubOAuth(parent=self)
+
+ def _patch_get_login_url(self, handler):
+ original_get_login_url = handler.get_login_url
+
+ def get_login_url():
+ """Return the Hub's login URL, to begin login redirect"""
+ login_url = self.hub_auth.login_url
+ # add state argument to OAuth url
+ state = self.hub_auth.set_state_cookie(
+ handler, next_url=handler.request.uri
+ )
+ login_url = url_concat(login_url, {'state': state})
+ # 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
+ with mock.patch.dict(
+ handler.application.settings, {"login_url": login_url}
+ ):
+ self.log.debug("Redirecting to login url: %s", login_url)
+ return original_get_login_url()
+
+ handler.get_login_url = get_login_url
+
+ async def get_user(self, handler):
+ if hasattr(handler, "_jupyterhub_user"):
+ return handler._jupyterhub_user
+ self._patch_get_login_url(handler)
+ user = await self.hub_auth.get_user(handler, sync=False)
+ if user is None:
+ handler._jupyterhub_user = None
+ return None
+ # check access scopes - don't allow even authenticated
+ # users with no access to this service past this stage.
+ self.log.debug(
+ f"Checking user {user['name']} with scopes {user['scopes']} against {self.hub_auth.access_scopes}"
+ )
+ scopes = self.hub_auth.check_scopes(self.hub_auth.access_scopes, user)
+ if scopes:
+ self.log.debug(f"Allowing user {user['name']} with scopes {scopes}")
+ else:
+ self.log.warning(f"Not allowing user {user['name']}")
+ # User is authenticated, but not authorized.
+ # Override redirect so if/when tornado @web.authenticated
+ # tries to redirect to login URL, 403 will be raised instead.
+ # This is not the best, but avoids problems that can be caused
+ # when get_current_user is allowed to raise,
+ # and avoids redirect loops for users who are logged it,
+ # but not allowed to access this resource.
+ def raise_on_redirect(*args, **kwargs):
+ raise HTTPError(403, "{kind} {name} is not allowed.".format(**user))
+
+ handler.redirect = raise_on_redirect
+
+ return None
+ handler._jupyterhub_user = JupyterHubUser(user)
+ return handler._jupyterhub_user
+
+ def get_handlers(self):
+ """Register our OAuth callback handler"""
+ return [
+ ("/logout", self.logout_handler_class),
+ (
+ "/oauth_callback",
+ JupyterHubOAuthCallbackHandler,
+ {"hub_auth": self.hub_auth},
+ ),
+ ]
+
+ def validate_security(self, app, ssl_options=None):
+ """Prevent warnings about security from base class"""
+ return
+
+ def page_config_hook(self, handler, page_config):
+ """JupyterLab page config hook
+
+ Adds JupyterHub info to page config.
+
+ Places the JupyterHub API token in PageConfig.token.
+
+ Only has effect on jupyterlab_server >=2.9
+ """
+ user = handler.current_user
+ # originally implemented in jupyterlab's LabApp
+ page_config["hubUser"] = user.name if user else ""
+ page_config["hubPrefix"] = hub_prefix = self.hub_auth.hub_prefix
+ page_config["hubHost"] = self.hub_auth.hub_host
+ page_config["shareUrl"] = url_path_join(hub_prefix, "user-redirect")
+ page_config["hubServerName"] = os.environ.get("JUPYTERHUB_SERVER_NAME", "")
+ page_config["token"] = self.hub_auth.get_token(handler) or ""
+ return page_config
+
+
+class JupyterHubAuthorizer(Authorizer):
+ """Authorizer that looks for permissions in JupyterHub scopes"""
+
+ # TODO: https://github.com/jupyter-server/jupyter_server/pull/830
+ hub_auth = Instance(HubOAuth)
+
+ @default("hub_auth")
+ def _default_hub_auth(self):
+ # HubAuth gets most of its config from the environment
+ return HubOAuth(parent=self)
+
+ def is_authorized(self, handler, user, action, resource):
+ # This is where we would implement granular scope checks,
+ # but until then,
+ # since the IdentityProvider doesn't allow users without access scopes,
+ # there's no further check to make.
+ # This scope check is redundant
+ have_scopes = self.hub_auth.check_scopes(
+ self.hub_auth.oauth_scopes, user.hub_user
+ )
+ self.log.debug(
+ f"{user.username} has permissions {have_scopes} required to {action} on {resource}"
+ )
+ return bool(have_scopes)
+
+
+def _fatal_errors(f):
+ """Decorator to make errors fatal to the server app
+
+ Ensures our extension is loaded or the server exits,
+ rather than starting a server without jupyterhub auth enabled.
+ """
+
+ @wraps(f)
+ def wrapped(self, *args, **kwargs):
+ try:
+ r = f(self, *args, **kwargs)
+ except Exception:
+ self.log.exception("Failed to load JupyterHubSingleUser server extension")
+ self.exit(1)
+
+ return wrapped
+
+
+# patches
+_original_jupyter_paths = None
+_jupyter_paths_without_home = None
+
+
+class JupyterHubSingleUser(ExtensionApp):
+ """Jupyter Server extension entrypoint.
+
+ Enables JupyterHub authentication
+ and some JupyterHub-specific configuration from environment variables
+
+ Server extensions are loaded before the rest of the server is set up
+ """
+
+ name = app_namespace = "jupyterhub-singleuser"
+ version = __version__
+ load_other_extensions = os.environ.get(
+ "JUPYTERHUB_SINGLEUSER_LOAD_OTHER_EXTENSIONS", "1"
+ ) not in {"", "0"}
+
+ # Most of this is _copied_ from the SingleUserNotebookApp mixin,
+ # which will be deprecated over time
+ # (i.e. once we can _require_ jupyter server 2.0)
+
+ # this is a _class_ attribute to deal with the lifecycle
+ # of when it's loaded vs when it's checked
+ disable_user_config = False
+
+ hub_auth = Instance(HubOAuth)
+
+ @default("hub_auth")
+ def _default_hub_auth(self):
+ # HubAuth gets most of its config from the environment
+ return HubOAuth(parent=self)
+
+ # create dynamic default http client,
+ # configured with any relevant ssl config
+ hub_http_client = Any()
+
+ @default('hub_http_client')
+ def _default_client(self):
+ ssl_context = make_ssl_context(
+ self.hub_auth.keyfile,
+ self.hub_auth.certfile,
+ cafile=self.hub_auth.client_ca,
+ )
+ AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context})
+ return AsyncHTTPClient()
+
+ async def check_hub_version(self):
+ """Test a connection to my Hub
+
+ - exit if I can't connect at all
+ - check version and warn on sufficient mismatch
+ """
+ client = self.hub_http_client
+ RETRIES = 5
+ for i in range(1, RETRIES + 1):
+ try:
+ resp = await client.fetch(self.hub_api_url)
+ except Exception:
+ self.log.exception(
+ "Failed to connect to my Hub at %s (attempt %i/%i). Is it running?",
+ self.hub_api_url,
+ i,
+ RETRIES,
+ )
+ await asyncio.sleep(min(2**i, 16))
+ else:
+ break
+ else:
+ self.exit(1)
+
+ hub_version = resp.headers.get('X-JupyterHub-Version')
+ _check_version(hub_version, __version__, self.log)
+
+ server_name = Unicode()
+
+ @default('server_name')
+ def _server_name_default(self):
+ return os.environ.get('JUPYTERHUB_SERVER_NAME', '')
+
+ hub_activity_url = Unicode(
+ config=True, help="URL for sending JupyterHub activity updates"
+ )
+
+ @default('hub_activity_url')
+ def _default_activity_url(self):
+ return os.environ.get('JUPYTERHUB_ACTIVITY_URL', '')
+
+ hub_activity_interval = Integer(
+ 300,
+ config=True,
+ help="""
+ Interval (in seconds) on which to update the Hub
+ with our latest activity.
+ """,
+ )
+
+ @default('hub_activity_interval')
+ def _default_activity_interval(self):
+ env_value = os.environ.get('JUPYTERHUB_ACTIVITY_INTERVAL')
+ if env_value:
+ return int(env_value)
+ else:
+ return 300
+
+ _last_activity_sent = Any(allow_none=True)
+
+ async def notify_activity(self):
+ """Notify jupyterhub of activity"""
+ client = self.hub_http_client
+ last_activity = self.web_app.last_activity()
+ if not last_activity:
+ self.log.debug("No activity to send to the Hub")
+ return
+ if last_activity:
+ # protect against mixed timezone comparisons
+ if not last_activity.tzinfo:
+ # assume naive timestamps are utc
+ self.log.warning("last activity is using naive timestamps")
+ last_activity = last_activity.replace(tzinfo=timezone.utc)
+
+ if self._last_activity_sent and last_activity < self._last_activity_sent:
+ self.log.debug("No activity since %s", self._last_activity_sent)
+ return
+
+ last_activity_timestamp = isoformat(last_activity)
+
+ async def notify():
+ self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
+ req = HTTPRequest(
+ url=self.hub_activity_url,
+ method='POST',
+ headers={
+ "Authorization": f"token {self.hub_auth.api_token}",
+ "Content-Type": "application/json",
+ },
+ body=json.dumps(
+ {
+ 'servers': {
+ self.server_name: {'last_activity': last_activity_timestamp}
+ },
+ 'last_activity': last_activity_timestamp,
+ }
+ ),
+ )
+ try:
+ await client.fetch(req)
+ except Exception:
+ self.log.exception("Error notifying Hub of activity")
+ return False
+ else:
+ return True
+
+ await exponential_backoff(
+ notify,
+ fail_message="Failed to notify Hub of activity",
+ start_wait=1,
+ max_wait=15,
+ timeout=60,
+ )
+ self._last_activity_sent = last_activity
+
+ async def keep_activity_updated(self):
+ if not self.hub_activity_url or not self.hub_activity_interval:
+ self.log.warning("Activity events disabled")
+ return
+ self.log.info(
+ "Updating Hub with activity every %s seconds", self.hub_activity_interval
+ )
+ while True:
+ try:
+ await self.notify_activity()
+ except Exception as e:
+ self.log.exception("Error notifying Hub of activity")
+ # add 20% jitter to the interval to avoid alignment
+ # of lots of requests from user servers
+ t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
+ await asyncio.sleep(t)
+
+ def _log_app_versions(self):
+ """Log application versions at startup
+
+ Logs versions of jupyterhub and singleuser-server base versions (jupyterlab, jupyter_server, notebook)
+ """
+ self.log.info(
+ f"Starting jupyterhub single-user server extension version {__version__}"
+ )
+
+ def load_config_file(self):
+ """Load JupyterHub singleuser config from the environment"""
+ self._log_app_versions()
+ if not os.environ.get('JUPYTERHUB_SERVICE_URL'):
+ raise KeyError("Missing required environment $JUPYTERHUB_SERVICE_URL")
+
+ cfg = self.config.ServerApp
+ cfg.identity_provider_class = JupyterHubIdentityProvider
+
+ # disable some single-user features
+ cfg.open_browser = False
+ cfg.trust_xheaders = True
+ cfg.quit_button = False
+ cfg.port_retries = 0
+ cfg.answer_yes = True
+ self.config.FileContentsManager.delete_to_trash = False
+
+ # load http server config from environment
+ url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
+ if url.port:
+ cfg.port = url.port
+ elif url.scheme == 'http':
+ cfg.port = 80
+ elif cfg.scheme == 'https':
+ cfg.port = 443
+ if url.hostname:
+ cfg.ip = url.hostname
+ else:
+ cfg.ip = "127.0.0.1"
+
+ cfg.base_url = os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
+
+ # load default_url at all kinds of priority,
+ # to make sure it has the desired effect
+ cfg.default_url = self.default_url = self.get_default_url()
+
+ # Jupyter Server default: config files have higher priority than extensions,
+ # by:
+ # 1. load config files
+ # 2. load extension config
+ # 3. merge file config into extension config
+
+ # we invert that by merging our extension config into server config before
+ # they get merged the other way
+ # this way config from this extension should always have highest priority
+ self.serverapp.update_config(self.config)
+
+ # add our custom templates
+ self.config.NotebookApp.extra_template_paths.append(SINGLEUSER_TEMPLATES_DIR)
+
+ @default("default_url")
+ def get_default_url(self):
+ # 1. explicit via _user_ config (?)
+ if 'default_url' in self.serverapp.config.ServerApp:
+ default_url = self.serverapp.config.ServerApp.default_url
+ self.log.info(f"Using default url from user config: {default_url}")
+ return default_url
+
+ # 2. explicit via JupyterHub admin config (c.Spawner.default_url)
+ default_url = os.environ.get("JUPYTERHUB_DEFAULT_URL")
+ if default_url:
+ self.log.info(
+ f"Using default url from environment $JUPYTERHUB_DEFAULT_URL: {default_url}"
+ )
+ return default_url
+
+ # 3. look for known UI extensions
+ # priority:
+ # 1. lab
+ # 2. nbclassic
+ # 3. retro
+
+ extension_points = self.serverapp.extension_manager.extension_points
+ for name in ["lab", "retro", "nbclassic"]:
+ if name in extension_points:
+ default_url = extension_points[name].app.default_url
+ if default_url and default_url != "/":
+ self.log.info(
+ f"Using default url from server extension {name}: {default_url}"
+ )
+ return default_url
+
+ self.log.warning(
+ "No default url found in config or known extensions, searching other extensions for default_url"
+ )
+ # 3. _any_ UI extension
+ # 2. discover other extensions
+ for (
+ name,
+ extension_point,
+ ) in extension_points.items():
+ app = extension_point.app
+ if app is self or not app:
+ continue
+ default_url = app.default_url
+ if default_url and default_url != "/":
+ self.log.info(
+ f"Using default url from server extension {name}: {default_url}"
+ )
+ return default_url
+
+ self.log.warning(
+ "Found no extension with a default URL, UI will likely be unavailable"
+ )
+ return "/"
+
+ def initialize_templates(self):
+ """Patch classic-noteboook page templates to add Hub-related buttons"""
+
+ app = self.serverapp
+
+ jinja_template_vars = app.jinja_template_vars
+
+ # override template vars
+ jinja_template_vars['logo_url'] = self.hub_auth.hub_host + url_path_join(
+ self.hub_auth.hub_prefix, 'logo'
+ )
+ jinja_template_vars[
+ 'hub_control_panel_url'
+ ] = self.hub_auth.hub_host + url_path_join(self.hub_auth.hub_prefix, 'home')
+
+ @_fatal_errors
+ def initialize(self, args=None):
+ # initialize takes place after
+ # 1. config has been loaded
+ # 2. Configurables instantiated
+ # 3. serverapp.web_app set up
+
+ super().initialize()
+ app = self.serverapp
+ app.web_app.settings[
+ "page_config_hook"
+ ] = app.identity_provider.page_config_hook
+ # add jupyterhub version header
+ headers = app.web_app.settings.setdefault("headers", {})
+ headers["X-JupyterHub-Version"] = __version__
+
+ disable_user_config = Bool()
+
+ @default("disable_user_config")
+ def _defaut_disable_user_config(self):
+ return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")
+
+ @staticmethod
+ def _disable_user_config(serverapp):
+ """
+ disable user-controlled sources of configuration
+ by excluding directories in their home
+ from paths.
+
+ This _does not_ disable frontend config,
+ such as UI settings persistence.
+
+ 1. Python config file paths
+ 2. Search paths for extensions, etc.
+ 3. import path
+ """
+
+ # config_file_paths is a property without a setter
+ # can't override on the instance
+ default_config_file_paths = serverapp.config_file_paths
+ config_file_paths = list(_exclude_home(default_config_file_paths))
+ serverapp.__class__.config_file_paths = property(
+ lambda self: config_file_paths,
+ )
+ # verify patch applied
+ assert serverapp.config_file_paths == config_file_paths
+
+ # patch jupyter_path to exclude $HOME
+ global _original_jupyter_paths, _jupyter_paths_without_home
+ _original_jupyter_paths = paths.jupyter_path()
+ _jupyter_paths_without_home = list(_exclude_home(_original_jupyter_paths))
+
+ def get_jupyter_path_without_home(*subdirs):
+ from jupyterhub.singleuser.extension import _original_jupyter_paths
+
+ paths = list(_original_jupyter_paths)
+ if subdirs:
+ paths = [os.path.join(p, *subdirs) for p in paths]
+ return paths
+
+ # patch `jupyter_path.__code__` to ensure all callers are patched,
+ # even if they've already imported
+ # this affects e.g. nbclassic.nbextension_paths
+ paths.jupyter_path.__code__ = get_jupyter_path_without_home.__code__
+
+ # prevent loading default static custom path in nbclassic
+ serverapp.config.NotebookApp.static_custom_path = []
+
+ @classmethod
+ def make_serverapp(cls, **kwargs):
+ """Instantiate the ServerApp
+
+ Override to customize the ServerApp before it loads any configuration
+ """
+ serverapp = super().make_serverapp(**kwargs)
+ if _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG"):
+ # disable user-controllable config
+ cls._disable_user_config(serverapp)
+
+ return serverapp
+
+
+main = JupyterHubSingleUser.launch_instance
+
+if __name__ == "__main__":
+ main()
diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py
index dfad82ca9e..9fa44bbd9e 100755
--- a/jupyterhub/singleuser/mixins.py
+++ b/jupyterhub/singleuser/mixins.py
@@ -226,7 +226,7 @@ def _exclude_home(path_list):
Used to disable per-user configuration.
"""
- home = os.path.expanduser('~')
+ home = os.path.expanduser('~/')
for p in path_list:
if not p.startswith(home):
yield p
@@ -935,11 +935,18 @@ def make_singleuser_app(App):
log = empty_parent_app.log
# detect base classes
- LoginHandler = empty_parent_app.login_handler_class
- LogoutHandler = empty_parent_app.logout_handler_class
+ if not getattr(empty_parent_app, "login_handler_class", None) and hasattr(
+ empty_parent_app, "identity_provider_class"
+ ):
+ has_handlers = empty_parent_app.identity_provider_class(parent=empty_parent_app)
+ else:
+ has_handlers = empty_parent_app
+ LoginHandler = has_handlers.login_handler_class
+ LogoutHandler = has_handlers.logout_handler_class
BaseHandler = _patch_app_base_handlers(empty_parent_app)
# create Handler classes from mixins + bases
+
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
pass
diff --git a/jupyterhub/singleuser/templates/page.html b/jupyterhub/singleuser/templates/page.html
new file mode 100644
index 0000000000..8ce5e8c72a
--- /dev/null
+++ b/jupyterhub/singleuser/templates/page.html
@@ -0,0 +1,44 @@
+{% extends "templates/page.html" %} {% block header_buttons %} {{super()}}
+
+
+
+ Control Panel
+
+
+{% endblock %} {% block logo %}
+
+{% endblock logo %} {% block script %} {{ super() }}
+
+{% endblock script %}
diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py
index 9bfbbf49b3..5798e763ac 100644
--- a/jupyterhub/tests/mocking.py
+++ b/jupyterhub/tests/mocking.py
@@ -36,12 +36,12 @@
from urllib.parse import urlparse
from pamela import PAMError
-from traitlets import Bool, Dict, default
+from tornado.ioloop import IOLoop
+from traitlets import Any, Bool, Dict, default
-from .. import metrics, orm, roles
+from .. import metrics, orm, roles, singleuser
from ..app import JupyterHub
from ..auth import PAMAuthenticator
-from ..singleuser import SingleUserNotebookApp
from ..spawner import SimpleLocalProcessSpawner
from ..utils import random_port, utcnow
from .utils import async_requests, public_url, ssl_setup
@@ -366,22 +366,55 @@ async def login_user(self, name):
# single-user-server mocking:
+if singleuser._as_extension:
+ from jupyterhub.singleuser.extension import JupyterHubSingleUser
+ class MockSingleUserServer(JupyterHubSingleUser):
-class MockSingleUserServer(SingleUserNotebookApp):
- """Mock-out problematic parts of single-user server when run in a thread
+ _start_callback = Any()
- Currently:
+ @classmethod
+ def make_serverapp(cls, **kwargs):
+ app = cls.serverapp_class(**kwargs)
- - disable signal handler
- """
+ def init_ioloop():
+ # make_current=True still needed for now
+ # jupyter-server 2.0 doesn't quite work yet without a current global loop
+ app.io_loop = IOLoop(make_current=False)
+ app.io_loop.make_current()
+ app.io_loop.add_callback(cls._start_callback, app)
- def init_signal(self):
- pass
+ app.init_ioloop = init_ioloop
+ app.log.parent = JupyterHub.instance().log
- @default("log_level")
- def _default_log_level(self):
- return 10
+ app.init_signal = lambda: None
+ app.log_level = 10
+ return app
+
+else:
+ from ..singleuser import SingleUserNotebookApp
+
+ class MockSingleUserServer(SingleUserNotebookApp):
+ """Mock-out problematic parts of single-user server when run in a thread
+
+ Currently:
+
+ - disable signal handler
+ """
+
+ _start_callback = Any()
+
+ def init_signal(self):
+ pass
+
+ @default("log_level")
+ def _default_log_level(self):
+ return 10
+
+ def start(self):
+ if self._start_callback:
+ self.io_loop.add_callback(self._start_callback)
+ return super().start()
class StubSingleUserSpawner(MockSpawner):
@@ -407,26 +440,36 @@ async def start(self):
env = self.get_env()
args = self.get_args()
evt = threading.Event()
- print(args, env)
+
+ def _start_callback(app):
+ self._app = app
+ evt.set()
+
+ MockSingleUserServer._start_callback = _start_callback
def _run():
with mock.patch.dict(os.environ, env):
- app = self._app = MockSingleUserServer()
- app.initialize(args)
- app.io_loop.add_callback(lambda: evt.set())
- assert app.hub_auth.oauth_client_id
- assert app.hub_auth.api_token
- assert app.hub_auth.oauth_scopes
- app.start()
+ server = MockSingleUserServer()
+ server.initialize(args)
+ server.start()
self._thread = threading.Thread(target=_run)
self._thread.start()
- ready = evt.wait(timeout=3)
+ ready = evt.wait(timeout=10)
assert ready
+ app = self._app
+ if singleuser._as_extension:
+ hub_auth = app.identity_provider.hub_auth
+ else:
+ hub_auth = app.hub_auth
+ assert hub_auth.oauth_client_id
+ assert hub_auth.api_token
+ assert hub_auth.access_scopes
return (ip, port)
async def stop(self):
- self._app.stop()
+ if hasattr(self, "_app"):
+ self._app.stop()
self._thread.join(timeout=30)
assert not self._thread.is_alive()
diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py
index e139b41fdc..961c88a6a6 100644
--- a/jupyterhub/tests/test_singleuser.py
+++ b/jupyterhub/tests/test_singleuser.py
@@ -134,7 +134,7 @@ async def test_singleuser_auth(
assert 'burgess' in r.text
-async def test_disable_user_config(app):
+async def test_disable_user_config(request, app, tmpdir):
# use StubSingleUserSpawner to launch a single-user app in a thread
app.spawner_class = StubSingleUserSpawner
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
@@ -148,6 +148,16 @@ async def test_disable_user_config(app):
# start with new config:
user.spawner.debug = True
user.spawner.disable_user_config = True
+ home_dir = tmpdir.join("home")
+ home_dir.mkdir()
+ # home_dir is defined on SimpleSpawner
+ user.spawner.home_dir = home = str(home_dir)
+ jupyter_config_dir = home_dir.join(".jupyter")
+ jupyter_config_dir.mkdir()
+ # verify config paths
+ with jupyter_config_dir.join("jupyter_server_config.py").open("w") as f:
+ f.write("c.TestSingleUser.jupyter_config_py = True")
+
await user.spawn()
await app.proxy.add_user(user)
@@ -161,6 +171,36 @@ async def test_disable_user_config(app):
)
assert r.status_code == 200
+ # set $HOME because some of these are properties,
+ # evaluated at call time rather than startup
+ with mock.patch.dict(os.environ, {"HOME": home}):
+
+ server_app = user.spawner._app
+ extensions = server_app.extension_manager.extension_points
+ if 'jupyterhub-singleuser' in extensions:
+ app = extensions['jupyterhub-singleuser'].app
+ else:
+ app = server_app
+ assert app.disable_user_config
+ server_config = server_app.config
+ # did not load ~/.jupyter/jupyter_server_config.py
+ assert 'TestSingleUser' not in server_config
+ import pprint
+
+ # nbclassic static paths (self.config_dir)
+ # nbclassic nbextensions path (jupyter_path, get_ipython_dir)
+
+ pprint.pprint(server_app.web_app.settings)
+ # check config paths
+ norm_home = os.path.realpath(os.path.abspath(home))
+ for path in server_app.config_file_paths:
+ path = os.path.realpath(os.path.abspath(path))
+ assert not path.startswith(norm_home + os.path.sep)
+
+ # TODO: check legacy notebook config
+ # nbextensions_path
+ # static_custom_path
+
def test_help_output():
out = check_output(
@@ -199,7 +239,7 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
have_notebook = True
if JUPYTERHUB_SINGLEUSER_APP.startswith("notebook."):
- expect_error = not have_notebook
+ expect_error = jupyterhub.singleuser._as_extension or not have_notebook
elif JUPYTERHUB_SINGLEUSER_APP.startswith("jupyter_server."):
expect_error = not have_server
else:
@@ -233,6 +273,10 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
async def test_nbclassic_control_panel(app, user):
+ if os.getenv("JUPYTERHUB_SINGLEUSER_EXTENSION") == "1":
+ pytest.xfail(
+ "control panel link in classic notebook is not available from server extension"
+ )
# use StubSingleUserSpawner to launch a single-user app in a thread
app.spawner_class = StubSingleUserSpawner
app.tornado_settings['spawner_class'] = StubSingleUserSpawner