279 lines
10 KiB
YAML
279 lines
10 KiB
YAML
|
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
|