From dbd047377e6d7f16c235795da578813cf274651f Mon Sep 17 00:00:00 2001 From: David Brochart Date: Wed, 2 Aug 2023 11:38:12 +0200 Subject: [PATCH] Add fps_auth_jupyterhub plugin (#335) --- .../jupyterhub_jupyverse_deployment.md | 155 +++++++++++++++ ....md => standalone_jupyverse_deployment.md} | 2 +- mkdocs.yml | 3 +- plugins/auth_jupyterhub/COPYING.md | 59 ++++++ plugins/auth_jupyterhub/README.md | 3 + .../fps_auth_jupyterhub/__init__.py | 3 + .../fps_auth_jupyterhub/config.py | 9 + .../auth_jupyterhub/fps_auth_jupyterhub/db.py | 23 +++ .../fps_auth_jupyterhub/launch.py | 22 +++ .../fps_auth_jupyterhub/main.py | 49 +++++ .../fps_auth_jupyterhub/models.py | 12 ++ .../fps_auth_jupyterhub/routes.py | 180 ++++++++++++++++++ plugins/auth_jupyterhub/pyproject.toml | 43 +++++ pyproject.toml | 8 +- 14 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 docs/tutorials/jupyterhub_jupyverse_deployment.md rename docs/tutorials/{deployment.md => standalone_jupyverse_deployment.md} (97%) create mode 100644 plugins/auth_jupyterhub/COPYING.md create mode 100644 plugins/auth_jupyterhub/README.md create mode 100644 plugins/auth_jupyterhub/fps_auth_jupyterhub/__init__.py create mode 100644 plugins/auth_jupyterhub/fps_auth_jupyterhub/config.py create mode 100644 plugins/auth_jupyterhub/fps_auth_jupyterhub/db.py create mode 100644 plugins/auth_jupyterhub/fps_auth_jupyterhub/launch.py create mode 100644 plugins/auth_jupyterhub/fps_auth_jupyterhub/main.py create mode 100644 plugins/auth_jupyterhub/fps_auth_jupyterhub/models.py create mode 100644 plugins/auth_jupyterhub/fps_auth_jupyterhub/routes.py create mode 100644 plugins/auth_jupyterhub/pyproject.toml diff --git a/docs/tutorials/jupyterhub_jupyverse_deployment.md b/docs/tutorials/jupyterhub_jupyverse_deployment.md new file mode 100644 index 00000000..b86e194c --- /dev/null +++ b/docs/tutorials/jupyterhub_jupyverse_deployment.md @@ -0,0 +1,155 @@ +In this tutorial, we will deploy Jupyverse through JupyterHub on a public [OVHcloud](https://www.ovhcloud.com) instance, and allow authentication using a [GitHub](https://github.com) account. + +## OVH setup + +### Create and connect to a public instance + +Let's follow the guide on [Creating and connecting to your first Public Cloud instance](https://help.ovhcloud.com/csm/en-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051009). We first need to create SSH keys, so that we can connect to our instance using SSH. Enter in a terminal: + +```bash +ssh-keygen -b 4096 +# Generating public/private rsa key pair. +# Enter file in which to save the key (/home/user/.ssh/id_rsa): +``` + +You can hit _Enter_. You are then asked to enter a passphrase, we will need it later. + +The public key can be accessed with: + +```bash +cat ~/.ssh/id_rsa.pub +``` + +Copy this public key into your clipboard. + +In the OVHcloud Control Panel, click on "Instances" and then "Create an instance". Choose the "B2-7" model, which is a light and general use instance, and click "Next". + +Select a region of you choice and click "Next". + +Select the "Ubuntu 23.04" image and click "Add a key" under "SSH key". Give it a name an paste your public key, then click "Next". Your instance should already be configured, you can click "Next" again. In the network configuration, make sure "Public mode" is checked, and click "Next". Then select your preferred billing period and click "Create an instance". + +Your instance should activate shortly. You can see it has a public IP, something like `1.2.3.4`. Let's connect to the instance using this IP address: + +```bash +ssh ubuntu@1.2.3.4 +# The authenticity of host '1.2.3.4 (1.2.3.4)' can't be established. +# ED25519 key fingerprint is SHA256:Q1&tbgX3fp9+7J90zyK0ctuKe1aqPoEY76Qi58uoSnA. +# This key is not known by any other names +# Are you sure you want to continue connecting (yes/no/[fingerprint])? +``` +Enter "yes", then enter your passphrase. You should now be connected to your instance. + +### Set up the environment + +Let's install [micromamba](https://mamba.readthedocs.io/en/latest/installation.html#micromamba) and configure it: + +```bash +sudo apt install bzip2 +curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest | tar -xvj bin/micromamba +bin/micromamba shell init --shell bash --root-prefix=~/micromamba +exec bash +``` + +Now create a conda environment and install Python and Node.js: + +```bash +micromamba create -n jupyterhub +micromamba activate jupyterhub +micromamba install -c conda-forge python nodejs +``` + +And install JupyterHub and Jupyverse: + +```bash +pip install jupyverse[jupyterlab,auth-jupyterhub] +pip install jupyter-collaboration +pip install oauthenticator +pip install https://github.com/davidbrochart/jupyterhub/archive/jupyverse.zip +npm install -g configurable-http-proxy +``` + +### Set up HTTPS + +For this you will need a domain name, like [https://my.jupyverse.com](https://my.jupyverse.com), that must point to your instance through its IP address. + +We'll use the [Certbot](https://certbot.eff.org) ACME client to manage SSL/TLS certificates. Enter in a terminal: + +```bash +sudo snap install --classic certbot +sudo ln -s /snap/bin/certbot /usr/bin/certbot +sudo certbot certonly --standalone +``` + +## Create a GitHub App + +We'll [register a new GitHub App](https://github.com/settings/apps/new). In the "GitHub App name", enter "JupyterHub-Jupyverse". In "Homepage URL", enter the URL of your public instance, [https://my.jupyverse.com](https://my.jupyverse.com). In "Callback URL", enter [https://my.jupyverse.com/hub/oauth_callback](https://my.jupyverse.com/hub/oauth_callback). Make sure "Expire user authorization tokens" and "Request user authorization (OAuth) during installation" are checked. Uncheck "Active" for "Webhook". In "Account permissions", give "Read-only" access to "Email addresses". Finally, hit the "Create GitHub App" button at the bottom. + +If this was successful, you can now generate a private key. Click on "Generate a new client secret", and copy it somewhere safe. Let's also copy the client ID shown on the same page. + +## Run the server + +### Configure JupyterHub + +Let's create a JupyterHub configuration file. Fill in the `allowed_users` and `admin_users` as you like. + +```bash +sudo mkdir /etc/jupyterhub +sudo vim /etc/jupyterhub/jupyterhub_config.py +``` + +With the following content: + +```py +# jupyterhub_config.py file +c = get_config() + +import os +pjoin = os.path.join + +runtime_dir = os.path.join('/srv/jupyterhub') + +# Allows multiple single-server per user +c.JupyterHub.allow_named_servers = True + +# https on :443 +c.JupyterHub.port = 443 +c.JupyterHub.ssl_key = '/etc/letsencrypt/live/jupyterhub.quantstack.net/privkey.pem' +c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/jupyterhub.quantstack.net/cert.pem' + +# put the JupyterHub cookie secret and state db +# in /var/run/jupyterhub +c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret') +c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite') +# or `--db=/path/to/jupyterhub.sqlite` on the command-line + +# use GitHub OAuthenticator for local users +c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator' +c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL'] + +# create system users that don't exist yet +c.LocalAuthenticator.create_system_users = True + +# specify users and admin +c.Authenticator.allowed_users = {'rgbkrk', 'minrk', 'jhamrick'} +c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'} +``` + +### Launch JupyterHub + +Let's launch JupyterHub with some environment variables. Use the GitHub client ID and secret of your GitHub App. + +```bash +sudo mkdir /srv/jupyterhub +chmod -R o+rx /home/ubuntu +mkdir jupyterhub +cd jupyterhub +sudo env "PATH=$PATH" \ +"OAUTH_CALLBACK_URL=https://my.jupyverse.com/hub/oauth_callback" \ +"GITHUB_CLIENT_ID=github_id" \ +"GITHUB_CLIENT_SECRET=github_secret" \ +jupyterhub -f /etc/jupyterhub/jupyterhub_config.py +``` + +Now open a browser window at [https://my.jupyverse.com](https://my.jupyverse.com), and click "Sign in with GitHub". Enter your credentials and click "Sign in". If you have two-factor authentication enabled on your GitHub account, you may have to approve the request by entering a code e.g. in your mobile phone GitHub application. + +After a while, JupyterLab should start. You should see an icon for your user in the top-right corner, with your initials. Any other connected user should be visible in the "Collaboration" tab on the left, and if you work on the same notebook, you should see them collaborate live! diff --git a/docs/tutorials/deployment.md b/docs/tutorials/standalone_jupyverse_deployment.md similarity index 97% rename from docs/tutorials/deployment.md rename to docs/tutorials/standalone_jupyverse_deployment.md index a01ce9d9..b1d6bdb9 100644 --- a/docs/tutorials/deployment.md +++ b/docs/tutorials/standalone_jupyverse_deployment.md @@ -1,4 +1,4 @@ -In this tutorial, we will deploy Jupyverse on a public [OVHcloud](https://www.ovhcloud.com) instance using [Fief](https://fief.dev), and allow authentication using a [GitHub](https://github.com) account. +In this tutorial, we will deploy Jupyverse as a standalone server on a public [OVHcloud](https://www.ovhcloud.com) instance using [Fief](https://fief.dev), and allow authentication using a [GitHub](https://github.com) account. ## OVH setup diff --git a/mkdocs.yml b/mkdocs.yml index e350f1ea..58033abe 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,7 +36,8 @@ nav: - usage/multi_user.md - usage/microservices.md - Turorials: - - tutorials/deployment.md + - tutorials/standalone_jupyverse_deployment.md + - tutorials/jupyterhub_jupyverse_deployment.md - Plugins: - 'auth': plugins/auth.md - 'contents': plugins/contents.md diff --git a/plugins/auth_jupyterhub/COPYING.md b/plugins/auth_jupyterhub/COPYING.md new file mode 100644 index 00000000..acc8b605 --- /dev/null +++ b/plugins/auth_jupyterhub/COPYING.md @@ -0,0 +1,59 @@ +# Licensing terms + +This project is licensed under the terms of the Modified BSD License +(also known as New or Revised or 3-Clause BSD), as follows: + +- Copyright (c) 2021-, Jupyter Development Team + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +Neither the name of the Jupyter Development Team nor the names of its +contributors may be used to endorse or promote products derived from this +software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## About the Jupyter Development Team + +The Jupyter Development Team is the set of all contributors to the Jupyter project. +This includes all of the Jupyter subprojects. + +The core team that coordinates development on GitHub can be found here: +https://github.com/jupyter/. + +## Our Copyright Policy + +Jupyter uses a shared copyright model. Each contributor maintains copyright +over their contributions to Jupyter. But, it is important to note that these +contributions are typically only changes to the repositories. Thus, the Jupyter +source code, in its entirety is not the copyright of any single person or +institution. Instead, it is the collective copyright of the entire Jupyter +Development Team. If individual contributors want to maintain a record of what +changes/contributions they have specific copyright on, they should indicate +their copyright in the commit message of the change, when they commit the +change to one of the Jupyter repositories. + +With this in mind, the following banner should be used in any source code file +to indicate the copyright and license terms: + + # Copyright (c) Jupyter Development Team. + # Distributed under the terms of the Modified BSD License. diff --git a/plugins/auth_jupyterhub/README.md b/plugins/auth_jupyterhub/README.md new file mode 100644 index 00000000..3af72ca9 --- /dev/null +++ b/plugins/auth_jupyterhub/README.md @@ -0,0 +1,3 @@ +# fps-auth-jupyterhub + +An FPS plugin for the authentication API, using JupyterHub. diff --git a/plugins/auth_jupyterhub/fps_auth_jupyterhub/__init__.py b/plugins/auth_jupyterhub/fps_auth_jupyterhub/__init__.py new file mode 100644 index 00000000..5c82d2dc --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.2.0" + +from .launch import launch # noqa diff --git a/plugins/auth_jupyterhub/fps_auth_jupyterhub/config.py b/plugins/auth_jupyterhub/fps_auth_jupyterhub/config.py new file mode 100644 index 00000000..35ed108f --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/config.py @@ -0,0 +1,9 @@ +from jupyverse_api.auth import AuthConfig +from pydantic import Field + + +class AuthJupyterHubConfig(AuthConfig): + db_url: str = Field( + description="The connection URL passed to create_engine()", + default="sqlite+aiosqlite:///:memory:", + ) diff --git a/plugins/auth_jupyterhub/fps_auth_jupyterhub/db.py b/plugins/auth_jupyterhub/fps_auth_jupyterhub/db.py new file mode 100644 index 00000000..7b321e86 --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/db.py @@ -0,0 +1,23 @@ +from sqlalchemy import JSON, Boolean, Column, String, Text +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase + + +class Base(AsyncAttrs, DeclarativeBase): + pass + + +class UserDB(Base): + __tablename__ = "user_account" + + token = Column(String(32), primary_key=True) + anonymous = Column(Boolean, default=True, nullable=False) + username = Column(String(length=32), nullable=False, unique=True) + name = Column(String(length=32), default="") + display_name = Column(String(length=32), default="") + initials = Column(String(length=8), nullable=True) + color = Column(String(length=32), nullable=True) + avatar_url = Column(String(length=32), nullable=True) + workspace = Column(Text(), default="{}", nullable=False) + settings = Column(Text(), default="{}", nullable=False) + permissions = Column(JSON, default={}, nullable=False) diff --git a/plugins/auth_jupyterhub/fps_auth_jupyterhub/launch.py b/plugins/auth_jupyterhub/fps_auth_jupyterhub/launch.py new file mode 100644 index 00000000..a66ba59c --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/launch.py @@ -0,0 +1,22 @@ +import os +from urllib.parse import urlparse + +from jupyverse_api.cli import main + + +def launch(): + service_url = os.environ.get("JUPYTERHUB_SERVICE_URL") + url = urlparse(service_url) + try: + return main.callback( + open_browser=True, + host=url.hostname, + port=url.port, + set_=[ + f"frontend.base_url={url.path}", + f"app.mount_path={url.path}", + ], + disable=[], + ) + except Exception: + return diff --git a/plugins/auth_jupyterhub/fps_auth_jupyterhub/main.py b/plugins/auth_jupyterhub/fps_auth_jupyterhub/main.py new file mode 100644 index 00000000..7b64025c --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/main.py @@ -0,0 +1,49 @@ +import httpx +from asphalt.core import Component, ContainerComponent, Context, context_teardown +from jupyverse_api.auth import Auth, AuthConfig +from jupyverse_api.app import App +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from .config import AuthJupyterHubConfig +from .db import Base +from .routes import auth_factory + + +class _AuthJupyterHubComponent(Component): + @context_teardown + async def start( + self, + ctx: Context, + ) -> None: + app = await ctx.request_resource(App) + db_session = await ctx.request_resource(AsyncSession) + db_engine = await ctx.request_resource(AsyncEngine) + + http_client = httpx.AsyncClient() + auth_jupyterhub = auth_factory(app, db_session, http_client) + ctx.add_resource(auth_jupyterhub, types=Auth) + + async with db_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield + + await http_client.aclose() + + +class AuthJupyterHubComponent(ContainerComponent): + def __init__(self, **kwargs): + self.auth_jupyterhub_config = AuthJupyterHubConfig(**kwargs) + super().__init__() + + async def start( + self, + ctx: Context, + ) -> None: + ctx.add_resource(self.auth_jupyterhub_config, types=AuthConfig) + self.add_component( + "sqlalchemy", + url=self.auth_jupyterhub_config.db_url, + ) + self.add_component("auth_jupyterhub", type=_AuthJupyterHubComponent) + await super().start(ctx) diff --git a/plugins/auth_jupyterhub/fps_auth_jupyterhub/models.py b/plugins/auth_jupyterhub/fps_auth_jupyterhub/models.py new file mode 100644 index 00000000..aa3f8bad --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/models.py @@ -0,0 +1,12 @@ +from typing import Dict, List + +from jupyverse_api.auth import User +from pydantic import ConfigDict + + +class JupyterHubUser(User): + model_config = ConfigDict(from_attributes=True) + + token: str + anonymous: bool = True + permissions: Dict[str, List[str]] diff --git a/plugins/auth_jupyterhub/fps_auth_jupyterhub/routes.py b/plugins/auth_jupyterhub/fps_auth_jupyterhub/routes.py new file mode 100644 index 00000000..a1fb4e23 --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/routes.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import asyncio +import json +import os +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + +import httpx +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, WebSocket, status +from fastapi.responses import RedirectResponse +from jupyterhub.services.auth import HubOAuth +from jupyterhub.utils import isoformat +from jupyverse_api import Router +from jupyverse_api.app import App +from jupyverse_api.auth import Auth, User +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from .db import UserDB +from .models import JupyterHubUser + + +def auth_factory( + app: App, + db_session: AsyncSession, + http_client: httpx.AsyncClient, +): + class AuthJupyterHub(Auth, Router): + def __init__(self) -> None: + super().__init__(app) + self.hub_auth = HubOAuth() + self.db_lock = asyncio.Lock() + self.activity_url = os.environ.get("JUPYTERHUB_ACTIVITY_URL") + self.server_name = os.environ.get("JUPYTERHUB_SERVER_NAME") + self.background_tasks = set() + + router = APIRouter() + + @router.get("/oauth_callback") + async def get_oauth_callback( + request: Request, + code: str | None = None, + state: str | None = None, + ): + if code is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + cookie_state = request.cookies.get(self.hub_auth.state_cookie_name) + if state is None or state != cookie_state: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + token = self.hub_auth.token_for_code(code) + hub_user = self.hub_auth.user_for_token(token) + async with self.db_lock: + db_session.add( + UserDB( + token=token, + name=hub_user["name"], + username=hub_user["name"], + ), + ) + await db_session.commit() + + next_url = self.hub_auth.get_next_url(cookie_state) + response = RedirectResponse(next_url) + response.set_cookie(key="jupyverse_jupyterhub_token", value=token) + return response + + @router.get("/api/me") + async def get_api_me( + request: Request, + user: User = Depends(self.current_user()), + ): + checked_permissions: Dict[str, List[str]] = {} + permissions = json.loads( + dict(request.query_params).get("permissions", "{}").replace("'", '"') + ) + if permissions: + user_permissions: Dict[str, List[str]] = {} + for resource, actions in permissions.items(): + user_resource_permissions = user_permissions.get(resource, []) + allowed = checked_permissions[resource] = [] + for action in actions: + if action in user_resource_permissions: + allowed.append(action) + + keys = ["username", "name", "display_name", "initials", "avatar_url", "color"] + identity = {k: getattr(user, k) for k in keys} + return { + "identity": identity, + "permissions": checked_permissions, + } + + self.include_router(router) + + def current_user(self, permissions: Optional[Dict[str, List[str]]] = None) -> Callable: + async def _( + request: Request, + jupyverse_jupyterhub_token: Annotated[Union[str, None], Cookie()] = None, + ): + if jupyverse_jupyterhub_token is not None: + async with self.db_lock: + user_db = await db_session.scalar( + select(UserDB).filter_by(token=jupyverse_jupyterhub_token) + ) + user = JupyterHubUser.model_validate(user_db) + if self.activity_url: + headers = { + "Authorization": f"token {self.hub_auth.api_token}", + "Content-Type": "application/json", + } + last_activity = isoformat(datetime.utcnow()) + task = asyncio.create_task( + http_client.post( + self.activity_url, + headers=headers, + json={ + "servers": {self.server_name: {"last_activity": last_activity}} + }, + ) + ) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + return user + + state = self.hub_auth.generate_state(next_url=str(request.url)) + raise HTTPException( + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + headers={ + "Location": f"{self.hub_auth.login_url}&state={state}", + "Set-Cookie": f"{self.hub_auth.state_cookie_name}={state}", + }, + ) + + return _ + + async def update_user( + self, jupyverse_jupyterhub_token: Annotated[Union[str, None], Cookie()] = None + ) -> Callable: + async def _(data: Dict[str, Any]) -> JupyterHubUser: + if jupyverse_jupyterhub_token is not None: + async with self.db_lock: + user_db = await db_session.scalar( + select(UserDB).filter_by(token=jupyverse_jupyterhub_token) + ) + for k, v in data.items(): + setattr(user_db, k, v) + await db_session.commit() + user = JupyterHubUser.model_validate(user_db) + return user + + return _ + + def websocket_auth( + self, + permissions: Optional[Dict[str, List[str]]] = None, + ) -> Callable[[], Tuple[Any, Dict[str, List[str]]]]: + async def _( + websocket: WebSocket, + ) -> Optional[Tuple[WebSocket, Optional[Dict[str, List[str]]]]]: + accept_websocket = False + if "jupyverse_jupyterhub_token" in websocket._cookies: + jupyverse_jupyterhub_token = websocket._cookies["jupyverse_jupyterhub_token"] + async with self.db_lock: + user_db = await db_session.scalar( + select(UserDB).filter_by(token=jupyverse_jupyterhub_token) + ) + if user_db: + accept_websocket = True + if accept_websocket: + return websocket, permissions + else: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return None + + return _ + + return AuthJupyterHub() diff --git a/plugins/auth_jupyterhub/pyproject.toml b/plugins/auth_jupyterhub/pyproject.toml new file mode 100644 index 00000000..62b5037b --- /dev/null +++ b/plugins/auth_jupyterhub/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = [ "hatchling",] +build-backend = "hatchling.build" + +[project] +name = "fps_auth_jupyterhub" +description = "An FPS plugin for the authentication API, using JupyterHbu" +keywords = ["jupyter", "server", "fastapi", "plugins"] +dynamic = ["version"] +requires-python = ">=3.8" +dependencies = [ + "asphalt-sqlalchemy >=5.0.1,<6", + "httpx >=0.24.1,<1", + "jupyterhub >=4.0.1,<5", + "jupyverse-api >=0.1.2,<1", +] + +[[project.authors]] +name = "Jupyter Development Team" +email = "jupyter@googlegroups.com" + +[project.readme] +file = "README.md" +content-type = "text/markdown" + +[project.license] +text = "BSD 3-Clause License" + +[project.urls] +Homepage = "https://jupyter.org" + +[tool.check-manifest] +ignore = [ ".*",] + +[tool.jupyter-releaser] +skip = [ "check-links" ] + +[project.entry-points] +"asphalt.components" = {auth_jupyterhub = "fps_auth_jupyterhub.main:AuthJupyterHubComponent"} +"jupyverse.components" = {auth_jupyterhub = "fps_auth_jupyterhub.main:AuthJupyterHubComponent"} + +[tool.hatch.version] +path = "fps_auth_jupyterhub/__init__.py" diff --git a/pyproject.toml b/pyproject.toml index 63179b5a..af2dff7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ jupyterlab = ["fps-jupyterlab >=0.1.2,<1"] retrolab = ["fps-retrolab >=0.1.2,<1"] auth = ["fps-auth >=0.1.2,<1", "fps-login >=0.1.2,<1"] auth-fief = ["fps-auth-fief >=0.1.2,<1"] +auth-jupyterhub = ["fps-auth-jupyterhub >=0.1.2,<1"] noauth = ["fps-noauth >=0.1.2,<1"] test = [ "mypy", @@ -82,6 +83,7 @@ matrix.auth.post-install-commands = [ { value = "pip install -e ./plugins/noauth", if = ["noauth"] }, { value = "pip install -e ./plugins/auth -e ./plugins/login", if = ["auth"] }, { value = "pip install -e ./plugins/auth_fief", if = ["auth_fief"] }, + { value = "pip install -e ./plugins/auth_jupyterhub", if = ["auth_jupyterhub"] }, ] matrix.frontend.scripts = [ @@ -93,11 +95,12 @@ matrix.auth.scripts = [ { key = "typecheck", value = "typecheck1 ./plugins/noauth", if = ["noauth"] }, { key = "typecheck", value = "typecheck1 ./plugins/auth ./plugins/login", if = ["auth"] }, { key = "typecheck", value = "typecheck1 ./plugins/auth_fief", if = ["auth_fief"] }, + { key = "typecheck", value = "typecheck1 ./plugins/auth_jupyterhub", if = ["auth_jupyterhub"] }, ] [[tool.hatch.envs.dev.matrix]] frontend = ["jupyterlab", "retrolab"] -auth = ["noauth", "auth", "auth_fief"] +auth = ["noauth", "auth", "auth_fief", "auth_jupyterhub"] [tool.hatch.envs.dev.scripts] test = "pytest ./tests plugins/webdav/tests -v --reruns 5 --timeout=60" @@ -144,6 +147,7 @@ python_packages = [ "plugins/noauth:fps-noauth", "plugins/auth:fps-auth", "plugins/auth_fief:fps-auth-fief", + "plugins/auth_jupyterhub:fps-auth-jupyterhub", "plugins/contents:fps-contents", "plugins/frontend:fps-frontend", "plugins/jupyterlab:fps-jupyterlab", @@ -156,7 +160,7 @@ python_packages = [ "plugins/resource_usage:fps-resource-usage", "plugins/login:fps-login", "plugins/webdav:fps-webdav", - ".:jupyverse:jupyverse_api,fps-noauth,fps-auth,fps-auth-fief,fps-contents,fps-jupyterlab,fps-kernels,fps-lab,fps-frontend,fps-nbconvert,fps-retrolab,fps-terminals,fps-yjs,fps-resource-usage,fps-webdav" + ".:jupyverse:jupyverse_api,fps-noauth,fps-auth,fps-auth-fief,fps-auth-jupyterhub,fps-contents,fps-jupyterlab,fps-kernels,fps-lab,fps-frontend,fps-nbconvert,fps-retrolab,fps-terminals,fps-yjs,fps-resource-usage,fps-webdav" ] [tool.hatch.version]