Skip to content

Commit

Permalink
Add fps_auth_jupyterhub plugin (#335)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidbrochart authored Aug 2, 2023
1 parent d989857 commit dbd0473
Show file tree
Hide file tree
Showing 14 changed files with 567 additions and 4 deletions.
155 changes: 155 additions & 0 deletions docs/tutorials/jupyterhub_jupyverse_deployment.md
Original file line number Diff line number Diff line change
@@ -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 [email protected]
# 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!
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions plugins/auth_jupyterhub/COPYING.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions plugins/auth_jupyterhub/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# fps-auth-jupyterhub

An FPS plugin for the authentication API, using JupyterHub.
3 changes: 3 additions & 0 deletions plugins/auth_jupyterhub/fps_auth_jupyterhub/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__version__ = "0.2.0"

from .launch import launch # noqa
9 changes: 9 additions & 0 deletions plugins/auth_jupyterhub/fps_auth_jupyterhub/config.py
Original file line number Diff line number Diff line change
@@ -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:",
)
23 changes: 23 additions & 0 deletions plugins/auth_jupyterhub/fps_auth_jupyterhub/db.py
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 22 additions & 0 deletions plugins/auth_jupyterhub/fps_auth_jupyterhub/launch.py
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions plugins/auth_jupyterhub/fps_auth_jupyterhub/main.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 12 additions & 0 deletions plugins/auth_jupyterhub/fps_auth_jupyterhub/models.py
Original file line number Diff line number Diff line change
@@ -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]]
Loading

0 comments on commit dbd0473

Please sign in to comment.