Skip to content

Commit cb59f2c

Browse files
committed
Add auth_fief plugin, register auth via entry point
1 parent 8f4a0cb commit cb59f2c

File tree

21 files changed

+302
-109
lines changed

21 files changed

+302
-109
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ You can currently authenticate as an anonymous user, or
9494
## With collaborative editing
9595

9696
```bash
97-
jupyverse --open-browser --auth.collaborative
97+
jupyverse --open-browser --lab.collaborative
9898
```
9999

100100
This is especially interesting if you are "user-authenticated", since your will appear as the

binder/jupyter_notebook_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"jupyverse",
44
"--no-open-browser",
55
"--auth.mode=noauth",
6-
"--auth.collaborative",
6+
"--lab.collaborative",
77
"--RetroLab.enabled=false",
88
"--Lab.base_url={base_url}jupyverse-jlab/",
99
"--port={port}",
@@ -17,7 +17,7 @@
1717
"jupyverse",
1818
"--no-open-browser",
1919
"--auth.mode=noauth",
20-
"--auth.collaborative",
20+
"--lab.collaborative",
2121
"--JupyterLab.enabled=false",
2222
"--Lab.base_url={base_url}jupyverse-rlab/",
2323
"--port={port}",

jupyverse/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
1+
import pkg_resources
2+
13
__version__ = "0.0.35"
4+
5+
auth = {ep.name: ep.load() for ep in pkg_resources.iter_entry_points(group="jupyverse_auth")}
6+
User = auth["User"]
7+
current_user = auth["current_user"]
8+
update_user = auth["update_user"]
9+
websocket_auth = auth["websocket_auth"]

plugins/auth/fps_auth/backends.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import uuid
2-
from typing import Generic, Optional
2+
from typing import Any, Dict, Generic, List, Optional
33

44
import httpx
55
from fastapi import Depends, HTTPException, Response, WebSocket, status
@@ -19,12 +19,13 @@
1919
from fastapi_users.db import SQLAlchemyUserDatabase
2020
from fps.exceptions import RedirectException # type: ignore
2121
from fps.logging import get_configured_logger # type: ignore
22+
from fps_lab.config import get_lab_config
2223
from httpx_oauth.clients.github import GitHubOAuth2 # type: ignore
2324
from starlette.requests import Request
2425

2526
from .config import get_auth_config
2627
from .db import User, get_user_db, secret
27-
from .models import UserCreate
28+
from .models import UserCreate, UserRead
2829

2930
logger = get_configured_logger("auth")
3031

@@ -106,8 +107,10 @@ async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db
106107
yield UserManager(user_db)
107108

108109

109-
async def get_enabled_backends(auth_config=Depends(get_auth_config)):
110-
if auth_config.mode == "noauth" and not auth_config.collaborative:
110+
async def get_enabled_backends(
111+
auth_config=Depends(get_auth_config), lab_config=Depends(get_lab_config)
112+
):
113+
if auth_config.mode == "noauth" and not lab_config.collaborative:
111114
return [noauth_authentication, github_cookie_authentication]
112115
else:
113116
return [cookie_authentication, github_cookie_authentication]
@@ -131,35 +134,33 @@ async def create_guest(user_manager, auth_config):
131134
password="",
132135
workspace=global_user.workspace,
133136
settings=global_user.settings,
137+
permissions={},
134138
)
135139
return await user_manager.create(UserCreate(**guest))
136140

137141

138-
def current_user(resource: Optional[str] = None):
142+
def current_user(permissions: Optional[Dict[str, List[str]]] = None):
139143
async def _(
140-
request: Request,
141144
response: Response,
142145
token: Optional[str] = None,
143146
user: Optional[User] = Depends(
144147
fapi_users.current_user(optional=True, get_enabled_backends=get_enabled_backends)
145148
),
146149
user_manager: UserManager = Depends(get_user_manager),
147150
auth_config=Depends(get_auth_config),
151+
lab_config=Depends(get_lab_config),
148152
):
149153
if auth_config.mode == "user":
150154
# "user" authentication: check authorization
151-
if user and resource:
152-
# check if allowed to access the resource
153-
permissions = user.permissions.get(resource, [])
154-
if request.method in ("GET", "HEAD"):
155-
if "read" not in permissions:
156-
user = None
157-
elif request.method in ("POST", "PUT", "PATCH", "DELETE"):
158-
if "write" not in permissions:
155+
if user and permissions:
156+
for resource, actions in permissions.items():
157+
user_actions_for_resource = user.permissions.get(resource, [])
158+
if not all([a in user_actions_for_resource for a in actions]):
159159
user = None
160+
break
160161
else:
161162
# "noauth" or "token" authentication
162-
if auth_config.collaborative:
163+
if lab_config.collaborative:
163164
if not user and auth_config.mode == "noauth":
164165
user = await create_guest(user_manager, auth_config)
165166
await cookie_authentication.login(get_jwt_strategy(), user, response)
@@ -188,7 +189,7 @@ async def _(
188189
return _
189190

190191

191-
def websocket_for_current_user(resource: str):
192+
def websocket_auth(permissions: Optional[Dict[str, List[str]]] = None):
192193
async def _(
193194
websocket: WebSocket,
194195
auth_config=Depends(get_auth_config),
@@ -202,8 +203,15 @@ async def _(
202203
user = await get_jwt_strategy().read_token(token, user_manager)
203204
if user:
204205
if auth_config.mode == "user":
205-
if "execute" in user.permissions.get(resource, []):
206+
# "user" authentication: check authorization
207+
if permissions is None:
206208
accept_websocket = True
209+
else:
210+
for resource, actions in permissions.items():
211+
user_actions_for_resource = user.permissions.get(resource, [])
212+
if any([a in user_actions_for_resource for a in actions]):
213+
accept_websocket = True
214+
break
207215
else:
208216
accept_websocket = True
209217
if accept_websocket:
@@ -213,3 +221,13 @@ async def _(
213221
return None
214222

215223
return _
224+
225+
226+
async def update_user(
227+
user: UserRead = Depends(current_user()), user_db: SQLAlchemyUserDatabase = Depends(get_user_db)
228+
):
229+
async def _(data: Dict[str, Any]) -> UserRead:
230+
await user_db.update(user, data)
231+
return user
232+
233+
return _

plugins/auth/fps_auth/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ class AuthConfig(PluginModel, BaseSettings):
1313
# mode: Literal["noauth", "token", "user"] = "token"
1414
mode: str = "token"
1515
token: str = str(uuid4())
16-
collaborative: bool = False
1716
global_email: str = "guest@jupyter.com"
1817
cookie_secure: bool = False # FIXME: should default to True, and set to False for tests
1918
clear_users: bool = False

plugins/auth/setup.cfg

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ python_requires = >=3.7
2121

2222
install_requires =
2323
fps >=0.0.8
24+
fps-lab
2425
aiosqlite
2526
fastapi-users[sqlalchemy,oauth] >=10.1.4,<11
2627

@@ -29,3 +30,8 @@ fps_router =
2930
fps-auth = fps_auth.routes
3031
fps_config =
3132
fps-auth = fps_auth.config
33+
jupyverse_auth =
34+
User = fps_auth.models:UserRead
35+
current_user = fps_auth.backends:current_user
36+
update_user = fps_auth.backends:update_user
37+
websocket_auth = fps_auth.backends:websocket_auth
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.0.35"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from fps.config import PluginModel, get_config
2+
from fps.hooks import register_config
3+
from pydantic import BaseSettings, SecretStr
4+
5+
6+
class AuthFiefConfig(PluginModel, BaseSettings):
7+
base_url: str # Base URL of Fief tenant
8+
client_id: str # ID of Fief client
9+
client_secret: SecretStr # Secret of Fief client
10+
11+
class Config(PluginModel.Config):
12+
env_prefix = "fps_auth_fief_"
13+
# config can be set with environment variables, e.g.:
14+
# export FPS_AUTH_FIEF_BASE_URL=https://jupyverse.fief.dev
15+
16+
17+
def get_auth_fief_config():
18+
return get_config(AuthFiefConfig)
19+
20+
21+
c = register_config(AuthFiefConfig)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from typing import Any, Dict, List, Optional
2+
3+
from fastapi import (
4+
APIRouter,
5+
Depends,
6+
HTTPException,
7+
Query,
8+
Request,
9+
Response,
10+
WebSocket,
11+
status,
12+
)
13+
from fastapi.responses import RedirectResponse
14+
from fastapi.security import APIKeyCookie
15+
from fief_client import FiefAccessTokenInfo, FiefAsync, FiefUserInfo
16+
from fief_client.integrations.fastapi import FiefAuth
17+
from fps.hooks import register_router
18+
from pydantic import BaseModel
19+
20+
from .config import get_auth_fief_config
21+
22+
23+
class CustomFiefAuth(FiefAuth):
24+
client: FiefAsync
25+
26+
async def get_unauthorized_response(self, request: Request, response: Response):
27+
redirect_uri = request.url_for("auth_callback")
28+
auth_url = await self.client.auth_url(redirect_uri, scope=["openid"])
29+
raise HTTPException(
30+
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
31+
headers={"Location": auth_url},
32+
)
33+
34+
35+
auth_fief_config = get_auth_fief_config()
36+
37+
fief = FiefAsync(
38+
auth_fief_config.base_url,
39+
auth_fief_config.client_id,
40+
auth_fief_config.client_secret.get_secret_value(),
41+
)
42+
43+
SESSION_COOKIE_NAME = "user_session"
44+
scheme = APIKeyCookie(name=SESSION_COOKIE_NAME, auto_error=False)
45+
auth = CustomFiefAuth(fief, scheme)
46+
47+
48+
router = APIRouter()
49+
50+
51+
@router.get("/auth-callback", name="auth_callback")
52+
async def auth_callback(request: Request, response: Response, code: str = Query(...)):
53+
redirect_uri = request.url_for("auth_callback")
54+
tokens, _ = await fief.auth_callback(code, redirect_uri)
55+
56+
response = RedirectResponse(request.url_for("root"))
57+
response.set_cookie(
58+
SESSION_COOKIE_NAME,
59+
tokens["access_token"],
60+
max_age=tokens["expires_in"],
61+
httponly=True,
62+
secure=False,
63+
)
64+
65+
return response
66+
67+
68+
async def update_user(
69+
user: FiefUserInfo = Depends(auth.current_user()),
70+
access_token_info: FiefAccessTokenInfo = Depends(auth.authenticated()),
71+
):
72+
async def _(data: Dict[str, Any]) -> FiefUserInfo:
73+
user = await fief.update_profile(access_token_info["access_token"], {"fields": data})
74+
print(f"{user=}")
75+
return user
76+
77+
return _
78+
79+
80+
def websocket_auth(permissions: Optional[Dict[str, List[str]]] = None):
81+
async def _(
82+
websocket: WebSocket,
83+
) -> Optional[WebSocket]:
84+
return websocket
85+
86+
return _
87+
88+
89+
r = register_router(router)
90+
91+
92+
class UserRead(BaseModel):
93+
workspace: str = "{}"
94+
settings: str = "{}"
95+
96+
97+
def current_user(permissions=None):
98+
if permissions is not None:
99+
permissions = [
100+
f"{resource}:{action}"
101+
for resource, actions in permissions.items()
102+
for action in actions
103+
]
104+
105+
async def _(user: FiefUserInfo = Depends(auth.current_user(permissions=permissions))):
106+
return UserRead(**user["fields"])
107+
108+
return _

plugins/auth_fief/setup.cfg

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[metadata]
2+
name = fps_auth_fief
3+
version = attr: fps_auth_fief.__version__
4+
description = An FPS plugin for the authentication API, using Fief
5+
long_description = file: README.md
6+
long_description_content_type = text/markdown
7+
license = BSD 3-Clause License
8+
author = Jupyter Development Team
9+
author_email = jupyter@googlegroups.com
10+
url = https://jupyter.org
11+
platforms = Windows, Linux, Mac OS X
12+
keywords = jupyter, server, fastapi, pluggy, plugins
13+
14+
[bdist_wheel]
15+
universal = 1
16+
17+
[options]
18+
include_package_data = True
19+
packages = find:
20+
python_requires = >=3.7
21+
22+
install_requires =
23+
fps >=0.0.8
24+
fief-client[fastapi]
25+
26+
[options.entry_points]
27+
fps_config =
28+
fps-auth-fief = fps_auth_fief.config
29+
fps_router =
30+
fps-auth-fief = fps_auth_fief.routes
31+
jupyverse_auth =
32+
User = fps_auth_fief.routes:UserRead
33+
current_user = fps_auth_fief.routes:current_user
34+
update_user = fps_auth_fief.routes:update_user
35+
websocket_auth = fps_auth_fief.routes:websocket_auth

0 commit comments

Comments
 (0)