fastapi service example

This commit is contained in:
Kafonek, Matt
2021-04-07 01:55:43 +00:00
parent c5bfd28005
commit 56269f0226
9 changed files with 318 additions and 0 deletions

View 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"]

View 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.

View File

@@ -0,0 +1 @@
from .app import app

View 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)

View 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)

View 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

View 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,
}

View 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]

View File

@@ -0,0 +1,4 @@
fastapi
uvicorn
httpx
python-multipart