mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 06:52:59 +00:00
fastapi service example
This commit is contained in:
13
examples/service-fastapi/Dockerfile
Normal file
13
examples/service-fastapi/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM jupyterhub/jupyterhub
|
||||
|
||||
# Create test user (PAM auth) and install single-user Jupyter
|
||||
RUN useradd testuser --create-home --shell /bin/bash
|
||||
RUN echo 'testuser:passwd' | chpasswd
|
||||
RUN pip install jupyter
|
||||
|
||||
COPY app ./app
|
||||
COPY jupyterhub_config.py .
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install -r /tmp/requirements.txt
|
||||
|
||||
CMD ["jupyterhub", "--ip", "0.0.0.0"]
|
105
examples/service-fastapi/README.md
Normal file
105
examples/service-fastapi/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Fastapi
|
||||
|
||||
[FastAPI](https://fastapi.tiangolo.com/) is a popular new web framework attractive for it's type hinting, async support, and [OpenAPI](https://github.com/OAI/OpenAPI-Specification) integration -- meaning you get a Swagger UI for your endpoints right out of the box.
|
||||
|
||||
The example Jupyter service here is built with FastAPI and runs with the ASGI server [uvicorn](https://www.uvicorn.org/). It hardly scratches the surface of FastAPI features, noteably not including any Pydantic models. The mechanics to highlight are the multiple auth options in `security.py` and testing authenticated vs non-authenticated endpoints with the Swagger UI.
|
||||
|
||||
# Swagger UI with OAuth demo
|
||||
|
||||
|
||||
|
||||
# Try it out locally
|
||||
|
||||
1. Install `fastapi` and other dependencies, then launch Jupyterhub
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
jupyterhub --ip=127.0.0.1
|
||||
```
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/fastapi or http://127.0.0.1:8000/services/fastapi/docs
|
||||
|
||||
3. Try interacting programmatically. If you create a new token in your control panel or pull out the `JUPYTERHUB_API_TOKEN` in the single user environment, you can skip the third step here.
|
||||
|
||||
```
|
||||
$ curl -X GET http://127.0.0.1:8000/services/fastapi/
|
||||
{"Hello":"World"}
|
||||
|
||||
$ curl -X GET http://127.0.0.1:8000/services/fastapi/me
|
||||
{"detail":"Must login with token parameter, cookie, or header"}
|
||||
|
||||
$ curl -X POST http://127.0.0.1:8000/hub/api/authorizations/token \
|
||||
-d '{"username": "myname", "password": "mypasswd!"}' \
|
||||
| jq '.token'
|
||||
"3fee13ce6d2845da9bd5f2c2170d3428"
|
||||
|
||||
$ curl -X GET http://127.0.0.1:8000/services/fastapi/me \
|
||||
-H "Authorization: Bearer 3fee13ce6d2845da9bd5f2c2170d3428" \
|
||||
| jq .
|
||||
{
|
||||
"kind": "user",
|
||||
"name": "myname",
|
||||
"admin": false,
|
||||
"groups": [],
|
||||
"server": null,
|
||||
"pending": null,
|
||||
"created": "2021-04-06T20:35:49.953710Z",
|
||||
"last_activity": "2021-04-06T20:50:15.541302Z",
|
||||
"servers": null
|
||||
}
|
||||
```
|
||||
|
||||
# Try it out in Docker
|
||||
|
||||
1. Build and run the Docker image locally
|
||||
|
||||
```bash
|
||||
sudo docker build . -t service-fastapi
|
||||
sudo docker run -it -p 8000:8000 service-fastapi
|
||||
```
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/fastapi/docs. When going through the OAuth flow or getting a token from the control panel, you can log in with `testuser` / `passwd`.
|
||||
|
||||
# PUBLIC_HOST
|
||||
|
||||
If you are running your service behind a proxy, or on a Docker / Kubernetes infrastructure, you might run into an error during OAuth that says `Mismatching redirect URI`. In the Jupterhub logs, there will be a warning along the lines of: `[W 2021-04-06 23:40:06.707 JupyterHub provider:498] Redirect uri https://jupyterhub.my.cloud/services/fastapi/oauth_callback != /services/fastapi/oauth_callback`. This happens because Swagger UI adds the host, as seen in the browser, to the Authorization URL.
|
||||
|
||||
To solve that problem, the `oauth_redirect_uri` value in the service initialization needs to match what Swagger will auto-generate and what the service will use when POST'ing to `/oauth2/token`. In this example, setting the `PUBLIC_HOST` environment variable to your public-facing Hub domain (e.g. `https://jupyterhub.my.cloud`) should make it work.
|
||||
|
||||
# Notes on security.py
|
||||
|
||||
FastAPI has a concept of a [dependency injection](https://fastapi.tiangolo.com/tutorial/dependencies) using a `Depends` object (and a subclass `Security`) that is automatically instantiated/executed when it is a parameter for your endpoint routes. You can utilize a `Depends` object for re-useable common parameters or authentication mechanisms like the [`get_user`](https://fastapi.tiangolo.com/tutorial/security/get-current-user) pattern.
|
||||
|
||||
JupyterHub OAuth has three ways to authenticate: a `token` url parameter; a `Authorization: Bearer <token>` header; and a `jupyterhub-services` cookie. FastAPI has helper functions that let us create `Security` (dependency injection) objects for each of those. When you need to allow multiple / optional authentication dependencies (`Security` objects), then you can use the argument `auto_error=False` and it will return `None` instead of raising an `HTTPException`.
|
||||
|
||||
Endpoints that need authentication (`/me` and `/debug` in this example) can leverage the `get_user` pattern and effectively pull the user model from the Hub API when a request has authenticated with cookie / token / header all using the simple syntax,
|
||||
|
||||
```python
|
||||
from .security import get_current_user
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: dict = Depends(get_current_user)):
|
||||
"Authenticated function that returns the User model"
|
||||
return user
|
||||
```
|
||||
|
||||
# Notes on client.py
|
||||
|
||||
FastAPI is designed to be an asyncronous web server, so the interactions with the Hub API should be made asyncronously as well. Instead of using `requests` to get user information from a token/cookie, this example uses [`httpx`](https://www.python-httpx.org/). `client.py` defines a small function that creates a `Client` (equivalent of `requests.Session`) with the Hub API url as it's `base_url` and adding the `JUPYTERHUB_API_TOKEN` to every header.
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
def get_client():
|
||||
base_url = os.environ["JUPYTERHUB_API_URL"]
|
||||
token = os.environ["JUPYTERHUB_API_TOKEN"]
|
||||
headers = {"Authorization": "Bearer %s" % token}
|
||||
return httpx.AsyncClient(base_url=base_url, headers=headers)
|
||||
|
||||
# use --
|
||||
async with get_client() as client:
|
||||
resp = await client.get('/endpoint')
|
||||
...
|
||||
```
|
||||
|
||||
This example did not try to match the complete parity of `jupyterhub.services.auth.HubAuth`, but it should be feasible to create an equivalent `HubAuth` class with async support.
|
1
examples/service-fastapi/app/__init__.py
Normal file
1
examples/service-fastapi/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .app import app
|
24
examples/service-fastapi/app/app.py
Normal file
24
examples/service-fastapi/app/app.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
### When managed by Jupyterhub, the actual endpoints
|
||||
### will be served out prefixed by /services/:name.
|
||||
### One way to handle this with FastAPI is to use an APIRouter.
|
||||
### All routes are defined in service.py
|
||||
from .service import router
|
||||
|
||||
app = FastAPI(
|
||||
title="Example FastAPI Service",
|
||||
version="0.1",
|
||||
### Serve out Swagger from the service prefix (<hub>/services/:name/docs)
|
||||
openapi_url=router.prefix + "/openapi.json",
|
||||
docs_url=router.prefix + "/docs",
|
||||
redoc_url=router.prefix + "/redoc",
|
||||
### Add our service client id to the /docs Authorize form automatically
|
||||
swagger_ui_init_oauth={"clientId": os.environ["JUPYTERHUB_CLIENT_ID"]},
|
||||
### Default /docs/oauth2 redirect will cause Hub
|
||||
### to raise oauth2 redirect uri mismatch errors
|
||||
swagger_ui_oauth2_redirect_url=os.environ["JUPYTERHUB_OAUTH_CALLBACK_URL"],
|
||||
)
|
||||
app.include_router(router)
|
13
examples/service-fastapi/app/client.py
Normal file
13
examples/service-fastapi/app/client.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
### For consideration: turn this into a class with
|
||||
### feature parity for HubAuth, or even subclass HubAuth?
|
||||
### See jupyterhub.services.auth.HubAuth for details
|
||||
def get_client():
|
||||
base_url = os.environ["JUPYTERHUB_API_URL"]
|
||||
token = os.environ["JUPYTERHUB_API_TOKEN"]
|
||||
headers = {"Authorization": "Bearer %s" % token}
|
||||
return httpx.AsyncClient(base_url=base_url, headers=headers)
|
83
examples/service-fastapi/app/security.py
Normal file
83
examples/service-fastapi/app/security.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException, Security, status
|
||||
from fastapi.security import OAuth2AuthorizationCodeBearer
|
||||
from fastapi.security.api_key import APIKeyCookie, APIKeyQuery
|
||||
|
||||
from .client import get_client
|
||||
|
||||
### For authenticated endpoints, we want to get auth from one of three ways
|
||||
### 1. "token" in the url params
|
||||
### 2. "jupyterhub-services" cookie (or config via env)
|
||||
### 3. Authorization Bearer header (with oauth to Hub support)
|
||||
auth_by_param = APIKeyQuery(name="token", auto_error=False)
|
||||
|
||||
COOKIE_NAME = os.getenv("JUPYTERHUB_COOKIE_NAME", "jupyterhub-services")
|
||||
auth_by_cookie = APIKeyCookie(name=COOKIE_NAME, auto_error=False)
|
||||
|
||||
if "PUBLIC_HOST" in os.environ:
|
||||
### When running in Docker or maybe other infrastructure,
|
||||
### JUPYTERHUB_API_URL is "http://jupyterhub" but we need to give
|
||||
### clients (swagger webpage) a link to the public url
|
||||
auth_url = os.environ["PUBLIC_HOST"] + "/hub/api/oauth2/authorize"
|
||||
else:
|
||||
auth_url = os.environ["JUPYTERHUB_API_URL"] + "/oauth2/authorize"
|
||||
auth_by_header = OAuth2AuthorizationCodeBearer(
|
||||
authorizationUrl=auth_url, tokenUrl="get_token", auto_error=False
|
||||
)
|
||||
### ^^ For Oauth in the Swagger webpage, we set the authorizationUrl
|
||||
### to the Hub /oauth2/authorize endpoint, so browser does a GET there and
|
||||
### receives a 'code' in return. Then the browser does a POST to our
|
||||
### internal /get_token endpoint with that code, and our server subsequently
|
||||
### POSTs to Hub /oauth2/token to get/return an acecss_token.
|
||||
### The reason for the double POST is that the client (swagger ui) doesn't have
|
||||
### the client_secret (JUPYTERHUB_API_TOKEN), but our server does.
|
||||
|
||||
### For consideration: build a pydantic User model
|
||||
### instead of just returning the dict from Hub api?
|
||||
### Also: optimize performance with a cache instead of
|
||||
### always hitting the Hub api?
|
||||
async def get_current_user(
|
||||
auth_by_cookie: str = Security(auth_by_cookie),
|
||||
auth_by_param: str = Security(auth_by_param),
|
||||
auth_by_header: str = Security(auth_by_header),
|
||||
):
|
||||
### Note all three Security functions are auto_error=False,
|
||||
### meaning if the scheme (header/cookie/param) isn't present
|
||||
### then they return None.
|
||||
### The cookie can be tricky. Navigating to the Hub login
|
||||
### page but not logging in still sets a cookie, but
|
||||
### Hub API returns a 404 if you query that cookie
|
||||
user = None
|
||||
if auth_by_param is not None or auth_by_header is not None:
|
||||
token = auth_by_param or auth_by_header
|
||||
async with get_client() as client:
|
||||
endpoint = "/authorizations/token/%s" % token
|
||||
resp = await client.get(endpoint)
|
||||
if resp.is_error:
|
||||
raise HTTPException(
|
||||
resp.status_code,
|
||||
detail={
|
||||
"msg": "Error getting user info from token",
|
||||
"request_url": str(resp.request.url),
|
||||
"token": token,
|
||||
"hub_response": resp.json(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
user = resp.json()
|
||||
|
||||
elif auth_by_cookie is not None:
|
||||
async with get_client() as client:
|
||||
endpoint = "/authorizations/cookie/%s/%s" % (COOKIE_NAME, auth_by_cookie)
|
||||
resp = await client.get(endpoint)
|
||||
if not resp.is_error:
|
||||
user = resp.json()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Must login with token parameter, cookie, or header",
|
||||
)
|
||||
else:
|
||||
return user
|
59
examples/service-fastapi/app/service.py
Normal file
59
examples/service-fastapi/app/service.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
|
||||
from .client import get_client
|
||||
from .security import get_current_user
|
||||
|
||||
# APIRouter prefix cannot end in /
|
||||
service_prefix = os.getenv("JUPYTERHUB_SERVICE_PREFIX", "").rstrip("/")
|
||||
router = APIRouter(prefix=service_prefix)
|
||||
|
||||
|
||||
@router.post("/get_token", include_in_schema=False)
|
||||
async def get_token(code: str = Form(...)):
|
||||
"Callback function for OAuth2AuthorizationCodeBearer scheme"
|
||||
# The only thing we need in this form post is the code
|
||||
# Everything else we can hardcode / pull from env
|
||||
async with get_client() as client:
|
||||
redirect_uri = (
|
||||
os.getenv("PUBLIC_HOST", "") + os.environ["JUPYTERHUB_OAUTH_CALLBACK_URL"],
|
||||
)
|
||||
data = {
|
||||
"client_id": os.environ["JUPYTERHUB_CLIENT_ID"],
|
||||
"client_secret": os.environ["JUPYTERHUB_API_TOKEN"],
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
resp = await client.post("/oauth2/token", data=data)
|
||||
### response is {'access_token': <token>, 'token_type': 'Bearer'}
|
||||
return resp.json()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def index():
|
||||
"Non-authenticated function that returns {'Hello': 'World'}"
|
||||
return {"Hello": "World"}
|
||||
|
||||
|
||||
# See security.py comment, consider a User model instead of dict?
|
||||
@router.get("/me")
|
||||
async def me(user: dict = Depends(get_current_user)):
|
||||
"Authenticated function that returns the User model"
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/debug")
|
||||
async def index(request: Request, user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Authenticated function that returns a few pieces of debug
|
||||
* Environ of the service process
|
||||
* Request headers
|
||||
* User model
|
||||
"""
|
||||
return {
|
||||
"env": dict(os.environ),
|
||||
"headers": dict(request.headers),
|
||||
"user": user,
|
||||
}
|
16
examples/service-fastapi/jupyterhub_config.py
Normal file
16
examples/service-fastapi/jupyterhub_config.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
service = {
|
||||
"name": "fastapi",
|
||||
"url": "http://127.0.0.1:10202",
|
||||
"command": ["uvicorn", "app:app", "--port", "10202"],
|
||||
}
|
||||
# If running behind a proxy, or in Docker / Kubernetes infrastructure,
|
||||
# you probably need to set a different public Hub host than the
|
||||
# internal JUPYTERHUB_API_URL host
|
||||
if "PUBLIC_HOST" in os.environ:
|
||||
public_host = os.environ["PUBLIC_HOST"]
|
||||
service["oauth_redirect_uri"] = f"{public_host}/services/fastapi/oauth_callback"
|
||||
service["environment"] = {"PUBLIC_HOST": public_host}
|
||||
|
||||
c.JupyterHub.services = [service]
|
4
examples/service-fastapi/requirements.txt
Normal file
4
examples/service-fastapi/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
httpx
|
||||
python-multipart
|
Reference in New Issue
Block a user