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 %} +Jupyter Notebook +{% 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