From 45315ff3f796e2ee99074538ab1eafb4ca7cead4 Mon Sep 17 00:00:00 2001 From: David Brochart Date: Thu, 27 Jul 2023 20:44:13 +0200 Subject: [PATCH] Add fps_auth_jupyterhub plugin --- plugins/auth_jupyterhub/COPYING.md | 59 +++++++++ plugins/auth_jupyterhub/README.md | 3 + .../fps_auth_jupyterhub/__init__.py | 1 + .../fps_auth_jupyterhub/config.py | 6 + .../auth_jupyterhub/fps_auth_jupyterhub/db.py | 23 ++++ .../fps_auth_jupyterhub/main.py | 42 +++++++ .../fps_auth_jupyterhub/models.py | 12 ++ .../fps_auth_jupyterhub/routes.py | 113 ++++++++++++++++++ plugins/auth_jupyterhub/pyproject.toml | 42 +++++++ pyproject.toml | 8 +- 10 files changed, 307 insertions(+), 2 deletions(-) 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/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/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..d3ec452c --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/__init__.py @@ -0,0 +1 @@ +__version__ = "0.2.0" 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..9e7f2ceb --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/config.py @@ -0,0 +1,6 @@ +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/main.py b/plugins/auth_jupyterhub/fps_auth_jupyterhub/main.py new file mode 100644 index 00000000..90363bf8 --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/main.py @@ -0,0 +1,42 @@ +from asphalt.core import Component, ContainerComponent, Context +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): + 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) + + auth_jupyterhub = auth_factory(app, db_session) + ctx.add_resource(auth_jupyterhub, types=Auth) + + async with db_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +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..aa132b32 --- /dev/null +++ b/plugins/auth_jupyterhub/fps_auth_jupyterhub/routes.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + +from fastapi import APIRouter, Cookie, HTTPException, Request, WebSocket, status +from fastapi.responses import RedirectResponse +from jupyterhub.services.auth import HubOAuth +from jupyverse_api import Router +from jupyverse_api.app import App +from jupyverse_api.auth import Auth +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, +): + hub_auth = HubOAuth() + + class AuthJupyterHub(Auth, Router): + def __init__(self) -> None: + super().__init__(app) + + 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(hub_auth.state_cookie_name) + if state is None or state != cookie_state: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + token = hub_auth.token_for_code(code) + hub_user = hub_auth.user_for_token(token) + db_session.add( + UserDB( + token=token, + name=hub_user["name"], + username=hub_user["name"], + ), + ) + await db_session.commit() + + next_url = hub_auth.get_next_url(cookie_state) + response = RedirectResponse(next_url) + response.set_cookie(key="jupyverse_jupyterhub_token", value=token) + return response + + 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: + user_db = await db_session.scalar(select(UserDB).filter_by(token=jupyverse_jupyterhub_token)) + user = JupyterHubUser.model_validate(user_db) + return user + + state = hub_auth.generate_state(next_url=str(request.url)) + raise HTTPException( + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + headers={ + "Location": f"{hub_auth.login_url}&state={state}", + "Set-Cookie": f"{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: + 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"] + 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..8d496b0e --- /dev/null +++ b/plugins/auth_jupyterhub/pyproject.toml @@ -0,0 +1,42 @@ +[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", + "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]