From 9b9bcd1487202268680bf686161fa321d8dc93d5 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Wed, 3 May 2023 11:19:31 +0200 Subject: [PATCH] JupyterHub code --- jupyter/base/values/values.yaml | 278 ++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 jupyter/base/values/values.yaml diff --git a/jupyter/base/values/values.yaml b/jupyter/base/values/values.yaml new file mode 100644 index 0000000..69f6a15 --- /dev/null +++ b/jupyter/base/values/values.yaml @@ -0,0 +1,278 @@ +hub: + config: + Authenticator: + auto_login: true + enable_auth_state: true + JupyterHub: + tornado_settings: + headers: { 'Content-Security-Policy': "frame-ancestors *;" } + extraConfig: + oauthCode: | + import json + import os + import socket + from collections import Mapping + from functools import lru_cache + from urllib.parse import urlencode, urlparse + + import requests + import tornado.options + import yaml + from jupyterhub.services.auth import HubAuthenticated + from jupyterhub.utils import url_path_join + from oauthenticator.generic import GenericOAuthenticator + from tornado.httpclient import AsyncHTTPClient, HTTPRequest + from tornado.httpserver import HTTPServer + from tornado.ioloop import IOLoop + from tornado.log import app_log + from tornado.web import Application, HTTPError, RequestHandler, authenticated + + + def post_auth_hook(authenticator, handler, authentication): + user = authentication['auth_state']['oauth_user']['ocs']['data']['id'] + auth_state = authentication['auth_state'] + authenticator.user_dict[user] = auth_state + return authentication + + + class NextcloudOAuthenticator(GenericOAuthenticator): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user_dict = {} + + def pre_spawn_start(self, user, spawner): + super().pre_spawn_start(user, spawner) + access_token = self.user_dict[user.name]['access_token'] + # refresh_token = self.user_dict[user.name]['refresh_token'] + spawner.environment['NEXTCLOUD_ACCESS_TOKEN'] = access_token + + + c.JupyterHub.authenticator_class = NextcloudOAuthenticator + c.NextcloudOAuthenticator.client_id = os.environ['NEXTCLOUD_CLIENT_ID'] + c.NextcloudOAuthenticator.client_secret = os.environ['NEXTCLOUD_CLIENT_SECRET'] + c.NextcloudOAuthenticator.login_service = 'Sunet Drive' + c.NextcloudOAuthenticator.username_key = lambda r: r.get('ocs', {}).get( + 'data', {}).get('id') + c.NextcloudOAuthenticator.userdata_url = 'https://' + os.environ[ + 'NEXTCLOUD_HOST'] + '/ocs/v2.php/cloud/user?format=json' + c.NextcloudOAuthenticator.authorize_url = 'https://' + os.environ[ + 'NEXTCLOUD_HOST'] + '/index.php/apps/oauth2/authorize' + c.NextcloudOAuthenticator.token_url = 'https://' + os.environ[ + 'NEXTCLOUD_HOST'] + '/index.php/apps/oauth2/api/v1/token' + c.NextcloudOAuthenticator.oauth_callback_url = 'https://' + os.environ[ + 'JUPYTER_HOST'] + '/hub/oauth_callback' + c.NextcloudOAuthenticator.refresh_pre_spawn = True + c.NextcloudOAuthenticator.enable_auth_state = True + c.NextcloudOAuthenticator.post_auth_hook = post_auth_hook + + + # memoize so we only load config once + @lru_cache() + def _load_config(): + """Load configuration from disk + Memoized to only load once + """ + cfg = {} + for source in ('config', 'secret'): + path = f"/etc/jupyterhub/{source}/values.yaml" + if os.path.exists(path): + print(f"Loading {path}") + with open(path) as f: + values = yaml.safe_load(f) + cfg = _merge_dictionaries(cfg, values) + else: + print(f"No config at {path}") + return cfg + + + def _merge_dictionaries(a, b): + """Merge two dictionaries recursively. + Simplified From https://stackoverflow.com/a/7205107 + """ + merged = a.copy() + for key in b: + if key in a: + if isinstance(a[key], Mapping) and isinstance(b[key], Mapping): + merged[key] = _merge_dictionaries(a[key], b[key]) + else: + merged[key] = b[key] + else: + merged[key] = b[key] + return merged + + + def get_config(key, default=None): + """ + Find a config item of a given name & return it + Parses everything as YAML, so lists and dicts are available too + get_config("a.b.c") returns config['a']['b']['c'] + """ + value = _load_config() + # resolve path in yaml + for level in key.split('.'): + if not isinstance(value, dict): + # a parent is a scalar or null, + # can't resolve full path + return default + if level not in value: + return default + else: + value = value[level] + return value + + + async def fetch_new_token(token_url, client_id, client_secret, refresh_token): + params = { + "grant_type": "refresh_token", + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + body = urlencode(params) + req = HTTPRequest(token_url, 'POST', body=body) + app_log.error("URL: %s body: %s", token_url, body) + + client = AsyncHTTPClient() + resp = await client.fetch(req) + + resp_json = json.loads(resp.body.decode('utf8', 'replace')) + return resp_json + + + class TokenHandler(HubAuthenticated, RequestHandler): + + def api_request(self, method, url, **kwargs): + """Make an API request""" + url = url_path_join(self.hub_auth.api_url, url) + allow_404 = kwargs.pop('allow_404', False) + headers = kwargs.setdefault('headers', {}) + headers.setdefault('Authorization', + 'token %s' % self.hub_auth.api_token) + try: + r = requests.request(method, url, **kwargs) + except requests.ConnectionError as e: + app_log.error("Error connecting to %s: %s", url, e) + msg = "Failed to connect to Hub API at %r." % url + msg += " Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname( + ) + if '127.0.0.1' in url: + msg += " Make sure to set c.JupyterHub.hub_ip to an IP accessible to" + \ + " single-user servers if the servers are not on the same host as the Hub." + raise HTTPError(500, msg) + + data = None + if r.status_code == 404 and allow_404: + pass + elif r.status_code == 403: + app_log.error( + "I don't have permission to check authorization with JupyterHub, my auth token may have expired: [%i] %s", + r.status_code, r.reason) + app_log.error(r.text) + raise HTTPError( + 500, + "Permission failure checking authorization, I may need a new token" + ) + elif r.status_code >= 500: + app_log.error("Upstream failure verifying auth token: [%i] %s", + r.status_code, r.reason) + app_log.error(r.text) + raise HTTPError( + 502, "Failed to check authorization (upstream problem)") + elif r.status_code >= 400: + app_log.warning("Failed to check authorization: [%i] %s", + r.status_code, r.reason) + app_log.warning(r.text) + raise HTTPError(500, "Failed to check authorization") + else: + data = r.json() + + return data + + @authenticated + async def get(self): + oauth_config = get_config('auth.custom.config') + + client_id = oauth_config['client_id'] + client_secret = oauth_config['client_secret'] + token_url = oauth_config['token_url'] + user_model = self.get_current_user() + + # Fetch current auth state + u = self.api_request('GET', url_path_join('users', user_model['name'])) + app_log.error("User: %s", u) + auth_state = u['auth_state'] + + new_tokens = await fetch_new_token(token_url, client_id, client_secret, + auth_state.get('refresh_token')) + + # update auth state in the hub + auth_state['access_token'] = new_tokens['access_token'] + auth_state['refresh_token'] = new_tokens['refresh_token'] + self.api_request('PATCH', + url_path_join('users', user_model['name']), + data=json.dumps({'auth_state': auth_state})) + + # send new token to the user + tokens = {'access_token': auth_state.get('access_token')} + self.set_header('content-type', 'application/json') + self.write(json.dumps(tokens, indent=1, sort_keys=True)) + + + class PingHandler(RequestHandler): + + def get(self): + self.set_header('content-type', 'application/json') + self.write(json.dumps({'ping': 1})) + + + def main(): + tornado.options.parse_command_line() + app = Application([ + (os.environ['JUPYTERHUB_SERVICE_PREFIX'] + 'tokens', TokenHandler), + (os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', PingHandler), + ]) + + http_server = HTTPServer(app) + url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL']) + + http_server.listen(url.port) + + IOLoop.current().start() + + + if __name__ == '__main__': + main() + extraEnv: + NEXTCLOUD_HOST: sunet.drive.test.sunet.se + JUPYTER_HOST: jupyter.drive.test.sunet.se + NEXTCLOUD_CLIENT_ID: + valueFrom: + secretKeyRef: + name: nextcloud-oauth-secrets + key: client-id + NEXTCLOUD_CLIENT_SECRET: + valueFrom: + secretKeyRef: + name: nextcloud-oauth-secrets + key: client-secret +singleuser: + image: + name: docker.sunet.se/drive/jupyter-custom + tag: 2023-02-28-2 + storage: + type: none + extraEnv: + JUPYTER_ENABLE_LAB: "yes" + extraFiles: + jupyter_notebook_config: + mountPath: /home/jovyan/.jupyter/jupyter_server_config.py + stringData: | + import os + c = get_config() + c.NotebookApp.allow_origin = '*' + c.NotebookApp.tornado_settings = { + 'headers': { 'Content-Security-Policy': "frame-ancestors *;" } + } + os.system('/usr/local/bin/nc-sync') + mode: 0644