Skip to content

Commit bdcfd0f

Browse files
Merge pull request #211 from davidbrochart/auth
Add auth_fief plugin, register auth via entry point, add WebSocket auth
2 parents 8f4a0cb + 18b2690 commit bdcfd0f

File tree

27 files changed

+432
-131
lines changed

27 files changed

+432
-131
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: 52 additions & 19 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, Tuple
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 # type: ignore
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,28 +189,60 @@ 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):
193+
"""
194+
A function returning a dependency for the WebSocket connection.
195+
196+
:param permissions: the permissions the user should be granted access to. The user should have
197+
access to at least one of them for the WebSocket to be opened.
198+
:returns: a dependency for the WebSocket connection. The dependency returns a tuple consisting
199+
of the websocket and the checked user permissions if the websocket is accepted, None otherwise.
200+
"""
201+
192202
async def _(
193203
websocket: WebSocket,
194204
auth_config=Depends(get_auth_config),
195205
user_manager: UserManager = Depends(get_user_manager),
196-
) -> Optional[WebSocket]:
206+
) -> Optional[Tuple[WebSocket, Optional[Dict[str, List[str]]]]]:
197207
accept_websocket = False
208+
checked_permissions: Optional[Dict[str, List[str]]] = None
198209
if auth_config.mode == "noauth":
199210
accept_websocket = True
200211
elif "fastapiusersauth" in websocket._cookies:
201212
token = websocket._cookies["fastapiusersauth"]
202213
user = await get_jwt_strategy().read_token(token, user_manager)
203214
if user:
204215
if auth_config.mode == "user":
205-
if "execute" in user.permissions.get(resource, []):
216+
# "user" authentication: check authorization
217+
if permissions is None:
206218
accept_websocket = True
219+
else:
220+
checked_permissions = {}
221+
for resource, actions in permissions.items():
222+
user_actions_for_resource = user.permissions.get(resource)
223+
if user_actions_for_resource is None:
224+
continue
225+
allowed = checked_permissions[resource] = []
226+
for action in actions:
227+
if action in user_actions_for_resource:
228+
allowed.append(action)
229+
accept_websocket = True
207230
else:
208231
accept_websocket = True
209232
if accept_websocket:
210-
return websocket
233+
return websocket, checked_permissions
211234
else:
212235
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
213236
return None
214237

215238
return _
239+
240+
241+
async def update_user(
242+
user: UserRead = Depends(current_user()), user_db: SQLAlchemyUserDatabase = Depends(get_user_db)
243+
):
244+
async def _(data: Dict[str, Any]) -> UserRead:
245+
await user_db.update(user, data)
246+
return user
247+
248+
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/fps_auth/routes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ async def startup():
105105

106106

107107
@router.get("/auth/users")
108-
async def get_users(user: UserRead = Depends(current_user("admin"))):
108+
async def get_users(user: UserRead = Depends(current_user(permissions={"admin": ["read"]}))):
109109
async with async_session_maker() as session:
110110
statement = select(User)
111111
users = (await session.execute(statement)).unique().all()
@@ -144,7 +144,7 @@ async def get_api_me(
144144

145145

146146
@users_router.get("/me")
147-
async def get_me(user: UserRead = Depends(current_user("admin"))):
147+
async def get_me(user: UserRead = Depends(current_user(permissions={"admin": ["read"]}))):
148148
return user
149149

150150

@@ -155,7 +155,7 @@ async def get_me(user: UserRead = Depends(current_user("admin"))):
155155
r_register = register_router(
156156
fapi_users.get_register_router(UserRead, UserCreate),
157157
prefix="/auth",
158-
dependencies=[Depends(current_user("admin"))],
158+
dependencies=[Depends(current_user(permissions={"admin": ["write"]}))],
159159
)
160160
r_user = register_router(users_router, prefix="/auth/user")
161161

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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from typing import Any, Dict, List, Optional, Tuple
2+
3+
from fastapi import Depends, HTTPException, Request, Response, WebSocket, status
4+
from fastapi.security import APIKeyCookie
5+
from fief_client import FiefAccessTokenInfo, FiefAsync, FiefUserInfo
6+
from fief_client.integrations.fastapi import FiefAuth
7+
8+
from .config import get_auth_fief_config
9+
from .models import UserRead
10+
11+
12+
class CustomFiefAuth(FiefAuth):
13+
client: FiefAsync
14+
15+
async def get_unauthorized_response(self, request: Request, response: Response):
16+
redirect_uri = request.url_for("auth_callback")
17+
auth_url = await self.client.auth_url(redirect_uri, scope=["openid"])
18+
raise HTTPException(
19+
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
20+
headers={"Location": auth_url},
21+
)
22+
23+
24+
auth_fief_config = get_auth_fief_config()
25+
26+
fief = FiefAsync(
27+
auth_fief_config.base_url,
28+
auth_fief_config.client_id,
29+
auth_fief_config.client_secret.get_secret_value(),
30+
)
31+
32+
SESSION_COOKIE_NAME = "fps_auth_fief_user_session"
33+
scheme = APIKeyCookie(name=SESSION_COOKIE_NAME, auto_error=False)
34+
auth = CustomFiefAuth(fief, scheme)
35+
36+
37+
async def update_user(
38+
user: FiefUserInfo = Depends(auth.current_user()),
39+
access_token_info: FiefAccessTokenInfo = Depends(auth.authenticated()),
40+
):
41+
async def _(data: Dict[str, Any]) -> FiefUserInfo:
42+
user = await fief.update_profile(access_token_info["access_token"], {"fields": data})
43+
return user
44+
45+
return _
46+
47+
48+
def websocket_auth(permissions: Optional[Dict[str, List[str]]] = None):
49+
async def _(
50+
websocket: WebSocket,
51+
) -> Optional[Tuple[WebSocket, Optional[Dict[str, List[str]]]]]:
52+
accept_websocket = False
53+
checked_permissions: Optional[Dict[str, List[str]]] = None
54+
if SESSION_COOKIE_NAME in websocket._cookies:
55+
access_token = websocket._cookies[SESSION_COOKIE_NAME]
56+
if permissions is None:
57+
accept_websocket = True
58+
else:
59+
checked_permissions = {}
60+
for resource, actions in permissions.items():
61+
allowed = checked_permissions[resource] = []
62+
for action in actions:
63+
try:
64+
await fief.validate_access_token(
65+
access_token, required_permissions=[f"{resource}:{action}"]
66+
)
67+
except BaseException:
68+
pass
69+
else:
70+
allowed.append(action)
71+
accept_websocket = True
72+
if accept_websocket:
73+
return websocket, checked_permissions
74+
else:
75+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
76+
return None
77+
78+
return _
79+
80+
81+
def current_user(permissions=None):
82+
if permissions is not None:
83+
permissions = [
84+
f"{resource}:{action}"
85+
for resource, actions in permissions.items()
86+
for action in actions
87+
]
88+
89+
async def _(user: FiefUserInfo = Depends(auth.current_user(permissions=permissions))):
90+
return UserRead(**user["fields"])
91+
92+
return _
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
# export FPS_AUTH_FIEF_CLIENT_ID=my_client_id
16+
# export FPS_AUTH_FIEF_CLIENT_SECRET=my_client_secret
17+
18+
19+
def get_auth_fief_config():
20+
return get_config(AuthFiefConfig)
21+
22+
23+
c = register_config(AuthFiefConfig)

0 commit comments

Comments
 (0)