fps_auth 0.9.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ # Licensing terms
2
+
3
+ This project is licensed under the terms of the Modified BSD License
4
+ (also known as New or Revised or 3-Clause BSD), as follows:
5
+
6
+ - Copyright (c) 2021-, Jupyter Development Team
7
+
8
+ All rights reserved.
9
+
10
+ Redistribution and use in source and binary forms, with or without
11
+ modification, are permitted provided that the following conditions are met:
12
+
13
+ Redistributions of source code must retain the above copyright notice, this
14
+ list of conditions and the following disclaimer.
15
+
16
+ Redistributions in binary form must reproduce the above copyright notice, this
17
+ list of conditions and the following disclaimer in the documentation and/or
18
+ other materials provided with the distribution.
19
+
20
+ Neither the name of the Jupyter Development Team nor the names of its
21
+ contributors may be used to endorse or promote products derived from this
22
+ software without specific prior written permission.
23
+
24
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
25
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
26
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
28
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
29
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
30
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
31
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
32
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
33
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34
+
35
+ ## About the Jupyter Development Team
36
+
37
+ The Jupyter Development Team is the set of all contributors to the Jupyter project.
38
+ This includes all of the Jupyter subprojects.
39
+
40
+ The core team that coordinates development on GitHub can be found here:
41
+ https://github.com/jupyter/.
42
+
43
+ ## Our Copyright Policy
44
+
45
+ Jupyter uses a shared copyright model. Each contributor maintains copyright
46
+ over their contributions to Jupyter. But, it is important to note that these
47
+ contributions are typically only changes to the repositories. Thus, the Jupyter
48
+ source code, in its entirety is not the copyright of any single person or
49
+ institution. Instead, it is the collective copyright of the entire Jupyter
50
+ Development Team. If individual contributors want to maintain a record of what
51
+ changes/contributions they have specific copyright on, they should indicate
52
+ their copyright in the commit message of the change, when they commit the
53
+ change to one of the Jupyter repositories.
54
+
55
+ With this in mind, the following banner should be used in any source code file
56
+ to indicate the copyright and license terms:
57
+
58
+ # Copyright (c) Jupyter Development Team.
59
+ # Distributed under the terms of the Modified BSD License.
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: fps_auth
3
+ Version: 0.9.5
4
+ Summary: An FPS plugin for the authentication API
5
+ Keywords: jupyter,server,fastapi,plugins
6
+ Author: Jupyter Development Team
7
+ Author-email: Jupyter Development Team <jupyter@googlegroups.com>
8
+ License-Expression: BSD-3-Clause
9
+ License-File: COPYING.md
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: BSD License
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: Implementation :: CPython
20
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
21
+ Requires-Dist: aiosqlite
22
+ Requires-Dist: fastapi-users[sqlalchemy,oauth]>=14.0.1,<15.0.0
23
+ Requires-Dist: jupyverse-api>=0.12.0,<0.13.0
24
+ Requires-Python: >=3.10
25
+ Project-URL: Homepage, https://jupyter.org
26
+ Description-Content-Type: text/markdown
27
+
28
+ # fps-auth
29
+
30
+ An FPS plugin for the authentication API.
@@ -0,0 +1,3 @@
1
+ # fps-auth
2
+
3
+ An FPS plugin for the authentication API.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["uv_build"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "fps_auth"
7
+ version = "0.9.5"
8
+ description = "An FPS plugin for the authentication API"
9
+ keywords = ["jupyter", "server", "fastapi", "plugins"]
10
+ requires-python = ">=3.10"
11
+ classifiers = [
12
+ "Development Status :: 5 - Production/Stable",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: BSD License",
15
+ "Programming Language :: Python",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: Python :: Implementation :: CPython",
22
+ "Programming Language :: Python :: Implementation :: PyPy",
23
+ ]
24
+ dependencies = [
25
+ "aiosqlite",
26
+ "fastapi-users[sqlalchemy,oauth] >=14.0.1,<15.0.0",
27
+ "jupyverse-api >=0.12.0,<0.13.0",
28
+ ]
29
+ license = "BSD-3-Clause"
30
+ license-files = ["COPYING.md"]
31
+
32
+ [[project.authors]]
33
+ name = "Jupyter Development Team"
34
+ email = "jupyter@googlegroups.com"
35
+
36
+ [project.readme]
37
+ file = "README.md"
38
+ content-type = "text/markdown"
39
+
40
+ [project.urls]
41
+ Homepage = "https://jupyter.org"
42
+
43
+ [project.entry-points]
44
+ "fps.modules" = {auth = "fps_auth.main:AuthModule"}
45
+ "jupyverse.modules" = {auth = "fps_auth.main:AuthModule"}
@@ -0,0 +1,6 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__ = importlib.metadata.version("fps_auth")
5
+ except importlib.metadata.PackageNotFoundError:
6
+ __version__ = "unknown"
@@ -0,0 +1,292 @@
1
+ import uuid
2
+ from dataclasses import dataclass
3
+ from typing import Any, Generic, cast
4
+
5
+ import httpx
6
+ from fastapi import Depends, HTTPException, Response, WebSocket, status
7
+ from fastapi_users import (
8
+ BaseUserManager,
9
+ FastAPIUsers,
10
+ UUIDIDMixin,
11
+ models,
12
+ )
13
+ from fastapi_users.authentication import (
14
+ AuthenticationBackend,
15
+ CookieTransport,
16
+ JWTStrategy,
17
+ )
18
+ from fastapi_users.authentication.strategy.base import Strategy
19
+ from fastapi_users.authentication.transport.base import Transport
20
+ from fastapi_users.db import SQLAlchemyUserDatabase
21
+ from httpx_oauth.clients.github import GitHubOAuth2
22
+ from jupyverse_api.exceptions import RedirectException
23
+ from jupyverse_api.frontend import FrontendConfig
24
+ from starlette.requests import Request
25
+
26
+ from .config import _AuthConfig
27
+ from .db import User
28
+ from .models import UserCreate, UserRead
29
+
30
+
31
+ @dataclass
32
+ class Res:
33
+ cookie_authentication: Any
34
+ current_user: Any
35
+ update_user: Any
36
+ fapi_users: Any
37
+ get_user_manager: Any
38
+ github_authentication: Any
39
+ github_cookie_authentication: Any
40
+ websocket_auth: Any
41
+
42
+
43
+ def get_backend(auth_config: _AuthConfig, frontend_config: FrontendConfig, db) -> Res:
44
+ class NoAuthScheme:
45
+ def __call__(self):
46
+ return "noauth"
47
+
48
+ class NoAuthTransport(Transport):
49
+ scheme = NoAuthScheme() # type: ignore
50
+
51
+ async def get_login_response(self, token: str) -> Response:
52
+ return Response()
53
+
54
+ async def get_logout_response(self) -> Response:
55
+ return Response()
56
+
57
+ @staticmethod
58
+ def get_openapi_login_responses_success():
59
+ pass
60
+
61
+ @staticmethod
62
+ def get_openapi_logout_responses_success():
63
+ pass
64
+
65
+ class NoAuthStrategy(Strategy, Generic[models.UP, models.ID]):
66
+ async def read_token(
67
+ self, token: str | None, user_manager: BaseUserManager[models.UP, models.ID]
68
+ ) -> models.UP | None:
69
+ active_user = await user_manager.user_db.get_by_email(auth_config.global_email)
70
+ return active_user
71
+
72
+ async def write_token(self, user: models.UP):
73
+ pass
74
+
75
+ async def destroy_token(self, token: str, user: models.UP):
76
+ pass
77
+
78
+ class GitHubTransport(CookieTransport):
79
+ async def get_login_response(self, token: str) -> Response:
80
+ response = await super().get_login_response(token)
81
+ response.status_code = status.HTTP_302_FOUND
82
+ response.headers["Location"] = "/lab"
83
+ return response
84
+
85
+ def get_noauth_strategy() -> NoAuthStrategy:
86
+ return NoAuthStrategy()
87
+
88
+ def get_jwt_strategy() -> JWTStrategy:
89
+ return JWTStrategy(secret=db.secret, lifetime_seconds=None)
90
+
91
+ noauth_authentication = AuthenticationBackend(
92
+ name="noauth",
93
+ transport=NoAuthTransport(),
94
+ get_strategy=get_noauth_strategy,
95
+ )
96
+
97
+ cookie_authentication = AuthenticationBackend(
98
+ name="cookie",
99
+ transport=CookieTransport(cookie_secure=auth_config.cookie_secure),
100
+ get_strategy=get_jwt_strategy,
101
+ )
102
+
103
+ github_cookie_authentication = AuthenticationBackend(
104
+ name="github",
105
+ transport=GitHubTransport(),
106
+ get_strategy=get_jwt_strategy,
107
+ )
108
+
109
+ github_authentication = GitHubOAuth2(auth_config.client_id, auth_config.client_secret)
110
+
111
+ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
112
+ async def on_after_register(self, user: User, request: Request | None = None):
113
+ for oauth_account in await user.awaitable_attrs.oauth_accounts:
114
+ if oauth_account.oauth_name == "github":
115
+ async with httpx.AsyncClient() as client:
116
+ r = (
117
+ await client.get(
118
+ f"https://api.github.com/user/{oauth_account.account_id}"
119
+ )
120
+ ).json()
121
+
122
+ await self.user_db.update(
123
+ user,
124
+ dict(
125
+ anonymous=False,
126
+ username=r["login"],
127
+ color=None,
128
+ avatar_url=r["avatar_url"],
129
+ is_active=True,
130
+ ),
131
+ )
132
+
133
+ async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(db.get_user_db)):
134
+ yield UserManager(user_db)
135
+
136
+ def get_enabled_backends():
137
+ if auth_config.mode == "noauth" and not frontend_config.collaborative:
138
+ res = [noauth_authentication, github_cookie_authentication]
139
+ else:
140
+ res = [cookie_authentication, github_cookie_authentication]
141
+ return res
142
+
143
+ fapi_users = FastAPIUsers[User, uuid.UUID](
144
+ get_user_manager,
145
+ [
146
+ noauth_authentication,
147
+ cookie_authentication,
148
+ github_cookie_authentication,
149
+ ],
150
+ )
151
+
152
+ async def create_guest(user_manager):
153
+ # workspace and settings are copied from global user
154
+ # but this is a new user
155
+ global_user = await user_manager.get_by_email(auth_config.global_email)
156
+ user_id = str(uuid.uuid4())
157
+ guest = dict(
158
+ anonymous=True,
159
+ email=f"{user_id}@jupyter.com",
160
+ username=f"{user_id}@jupyter.com",
161
+ password="",
162
+ workspace=global_user.workspace,
163
+ settings=global_user.settings,
164
+ permissions={},
165
+ )
166
+ return await user_manager.create(UserCreate(**guest))
167
+
168
+ def current_user(permissions: dict[str, list[str]] | None = None):
169
+ async def _(
170
+ response: Response,
171
+ token: str | None = None,
172
+ user: User | None = Depends(
173
+ fapi_users.current_user(optional=True, get_enabled_backends=get_enabled_backends)
174
+ ),
175
+ user_manager: BaseUserManager[User, models.ID] = Depends(get_user_manager),
176
+ ):
177
+ if auth_config.mode == "user":
178
+ # "user" authentication: check authorization
179
+ if user and permissions:
180
+ for resource, actions in permissions.items():
181
+ user_actions_for_resource = user.permissions.get(resource, [])
182
+ if not all([a in user_actions_for_resource for a in actions]):
183
+ user = None
184
+ break
185
+ else:
186
+ # "noauth" or "token" authentication
187
+ if frontend_config.collaborative:
188
+ if not user and auth_config.mode == "noauth":
189
+ user = await create_guest(user_manager)
190
+ token = await get_jwt_strategy().write_token(user)
191
+ cookie_transport = cast(CookieTransport, cookie_authentication.transport)
192
+ cookie_transport._set_login_cookie(response, token)
193
+
194
+ elif not user and auth_config.mode == "token":
195
+ global_user = await user_manager.get_by_email(auth_config.global_email)
196
+ if global_user and global_user.username == token:
197
+ user = await create_guest(user_manager)
198
+ token = await get_jwt_strategy().write_token(user)
199
+ cookie_transport = cast(
200
+ CookieTransport, cookie_authentication.transport
201
+ )
202
+ cookie_transport._set_login_cookie(response, token)
203
+ else:
204
+ if auth_config.mode == "token":
205
+ global_user = await user_manager.get_by_email(auth_config.global_email)
206
+ if global_user and global_user.username == token:
207
+ user = global_user
208
+ token = await get_jwt_strategy().write_token(user)
209
+ cookie_transport = cast(
210
+ CookieTransport, cookie_authentication.transport
211
+ )
212
+ cookie_transport._set_login_cookie(response, token)
213
+
214
+ if user:
215
+ return user
216
+
217
+ elif auth_config.login_url:
218
+ raise RedirectException(auth_config.login_url)
219
+
220
+ else:
221
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
222
+
223
+ return _
224
+
225
+ def websocket_auth(permissions: dict[str, list[str]] | None = None):
226
+ """
227
+ A function returning a dependency for the WebSocket connection.
228
+
229
+ :param permissions: the permissions the user should be granted access to. The user should
230
+ have access to at least one of them for the WebSocket to be opened.
231
+ :returns: a dependency for the WebSocket connection. The dependency returns a tuple
232
+ consisting of the websocket and the checked user permissions if the websocket is accepted,
233
+ None otherwise.
234
+ """
235
+
236
+ async def _(
237
+ websocket: WebSocket,
238
+ user_manager: BaseUserManager[models.UP, models.ID] = Depends(get_user_manager),
239
+ ) -> tuple[WebSocket, dict[str, list[str]] | None] | None:
240
+ accept_websocket = False
241
+ checked_permissions: dict[str, list[str]] | None = None
242
+ if auth_config.mode == "noauth":
243
+ accept_websocket = True
244
+ elif "fastapiusersauth" in websocket._cookies:
245
+ token = websocket._cookies["fastapiusersauth"]
246
+ user = await get_jwt_strategy().read_token(token, user_manager)
247
+ if user:
248
+ if auth_config.mode == "user":
249
+ # "user" authentication: check authorization
250
+ if permissions is None:
251
+ accept_websocket = True
252
+ else:
253
+ checked_permissions = {}
254
+ for resource, actions in permissions.items():
255
+ user_actions_for_resource = user.permissions.get(resource)
256
+ if user_actions_for_resource is None:
257
+ continue
258
+ allowed = checked_permissions[resource] = []
259
+ for action in actions:
260
+ if action in user_actions_for_resource:
261
+ allowed.append(action)
262
+ accept_websocket = True
263
+ else:
264
+ accept_websocket = True
265
+ if accept_websocket:
266
+ return websocket, checked_permissions
267
+ else:
268
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
269
+ return None
270
+
271
+ return _
272
+
273
+ async def update_user(
274
+ user: UserRead = Depends(current_user()),
275
+ user_db: SQLAlchemyUserDatabase = Depends(db.get_user_db),
276
+ ):
277
+ async def _(data: dict[str, Any]) -> UserRead:
278
+ await user_db.update(user, data)
279
+ return user
280
+
281
+ return _
282
+
283
+ return Res(
284
+ cookie_authentication=cookie_authentication,
285
+ current_user=current_user,
286
+ update_user=update_user,
287
+ fapi_users=fapi_users,
288
+ get_user_manager=get_user_manager,
289
+ github_authentication=github_authentication,
290
+ github_cookie_authentication=github_cookie_authentication,
291
+ websocket_auth=websocket_auth,
292
+ )
@@ -0,0 +1,18 @@
1
+ from uuid import uuid4
2
+
3
+ from jupyverse_api.auth import AuthConfig
4
+
5
+
6
+ class _AuthConfig(AuthConfig):
7
+ client_id: str = ""
8
+ client_secret: str = ""
9
+ redirect_uri: str = ""
10
+ # mode: Literal["noauth", "token", "user"] = "token"
11
+ mode: str = "token"
12
+ token: str = uuid4().hex
13
+ global_email: str = "guest@jupyter.com"
14
+ cookie_secure: bool = False # FIXME: should default to True, and set to False for tests
15
+ clear_users: bool = False
16
+ test: bool = False
17
+ login_url: str | None = None
18
+ directory: str | None = None
@@ -0,0 +1,102 @@
1
+ import secrets
2
+ from collections.abc import AsyncGenerator
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from fastapi import Depends
8
+ from fastapi_users.db import (
9
+ SQLAlchemyBaseOAuthAccountTableUUID,
10
+ SQLAlchemyBaseUserTableUUID,
11
+ SQLAlchemyUserDatabase,
12
+ )
13
+ from sqlalchemy import JSON, Boolean, Column, String, Text
14
+ from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine
15
+ from sqlalchemy.orm import DeclarativeBase, Mapped, relationship
16
+
17
+ from .config import _AuthConfig
18
+
19
+
20
+ class Base(AsyncAttrs, DeclarativeBase):
21
+ pass
22
+
23
+
24
+ class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
25
+ pass
26
+
27
+
28
+ class User(SQLAlchemyBaseUserTableUUID, Base):
29
+ anonymous = Column(Boolean, default=True, nullable=False)
30
+ username = Column(String(length=32), nullable=False, unique=True)
31
+ name = Column(String(length=32), default="")
32
+ display_name = Column(String(length=32), default="")
33
+ initials = Column(String(length=8), nullable=True)
34
+ color = Column(String(length=32), nullable=True)
35
+ avatar_url = Column(String(length=32), nullable=True)
36
+ workspace = Column(Text(), default="{}", nullable=False)
37
+ settings = Column(Text(), default="{}", nullable=False)
38
+ permissions = Column(JSON, default={}, nullable=False)
39
+ oauth_accounts: Mapped[list[OAuthAccount]] = relationship("OAuthAccount", lazy="joined")
40
+
41
+
42
+ @dataclass
43
+ class Res:
44
+ User: Any
45
+ async_session_maker: Any
46
+ create_db_and_tables: Any
47
+ get_async_session: Any
48
+ get_user_db: Any
49
+ secret: Any
50
+
51
+
52
+ def get_db(auth_config: _AuthConfig) -> Res:
53
+ jupyter_dir = (
54
+ Path.home() / ".local" / "share" / "jupyter"
55
+ if auth_config.directory is None
56
+ else Path(auth_config.directory)
57
+ )
58
+ jupyter_dir.mkdir(parents=True, exist_ok=True)
59
+ name = "jupyverse"
60
+ if auth_config.test:
61
+ name += "_test"
62
+ secret_path = jupyter_dir / f"{name}_secret"
63
+ userdb_path = jupyter_dir / f"{name}_users.db"
64
+
65
+ if auth_config.clear_users:
66
+ if userdb_path.is_file():
67
+ userdb_path.unlink()
68
+ if secret_path.is_file():
69
+ secret_path.unlink()
70
+ if auth_config.mode == "token":
71
+ if secret_path.is_file():
72
+ secret_path.unlink()
73
+
74
+ if not secret_path.is_file():
75
+ secret_path.write_text(secrets.token_hex(32))
76
+
77
+ secret = secret_path.read_text()
78
+
79
+ database_url = f"sqlite+aiosqlite:///{userdb_path}"
80
+
81
+ engine = create_async_engine(database_url)
82
+ async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
83
+
84
+ async def create_db_and_tables():
85
+ async with engine.begin() as conn:
86
+ await conn.run_sync(Base.metadata.create_all)
87
+
88
+ async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
89
+ async with async_session_maker() as session:
90
+ yield session
91
+
92
+ async def get_user_db(session: AsyncSession = Depends(get_async_session)):
93
+ yield SQLAlchemyUserDatabase(session, User, OAuthAccount)
94
+
95
+ return Res(
96
+ User=User,
97
+ async_session_maker=async_session_maker,
98
+ create_db_and_tables=create_db_and_tables,
99
+ get_async_session=get_async_session,
100
+ get_user_db=get_user_db,
101
+ secret=secret,
102
+ )
@@ -0,0 +1,59 @@
1
+ import structlog
2
+ from fastapi_users.exceptions import UserAlreadyExists
3
+ from fps import Module
4
+ from jupyverse_api.app import App
5
+ from jupyverse_api.auth import Auth, AuthConfig
6
+ from jupyverse_api.frontend import FrontendConfig
7
+ from jupyverse_api.main import QueryParams
8
+
9
+ from .config import _AuthConfig
10
+ from .routes import auth_factory
11
+
12
+ log = structlog.get_logger()
13
+
14
+
15
+ class AuthModule(Module):
16
+ def __init__(self, name: str, **kwargs):
17
+ super().__init__(name)
18
+ self.config = _AuthConfig(**kwargs)
19
+
20
+ async def prepare(self) -> None:
21
+ self.put(self.config, AuthConfig)
22
+
23
+ app = await self.get(App)
24
+ frontend_config = await self.get(FrontendConfig)
25
+
26
+ auth = auth_factory(app, self.config, frontend_config)
27
+ self.put(auth, Auth)
28
+
29
+ await auth.db.create_db_and_tables()
30
+
31
+ if self.config.test:
32
+ try:
33
+ await auth.create_user(
34
+ username="admin@jupyter.com",
35
+ email="admin@jupyter.com",
36
+ password="jupyverse",
37
+ permissions={"admin": ["read", "write"]},
38
+ )
39
+ except UserAlreadyExists:
40
+ pass
41
+
42
+ try:
43
+ await auth.create_user(
44
+ username=self.config.token,
45
+ email=self.config.global_email,
46
+ password="",
47
+ permissions={},
48
+ )
49
+ except UserAlreadyExists:
50
+ global_user = await auth.get_user_by_email(self.config.global_email)
51
+ await auth._update_user(
52
+ global_user,
53
+ username=self.config.token,
54
+ permissions={},
55
+ )
56
+
57
+ if self.config.mode == "token":
58
+ query_params = await self.get(QueryParams)
59
+ query_params.d["token"] = self.config.token
@@ -0,0 +1,21 @@
1
+ import uuid
2
+
3
+ from fastapi_users import schemas
4
+ from jupyverse_api.auth import User
5
+
6
+
7
+ class JupyterUser(User):
8
+ anonymous: bool = True
9
+ permissions: dict[str, list[str]]
10
+
11
+
12
+ class UserRead(schemas.BaseUser[uuid.UUID], JupyterUser):
13
+ pass
14
+
15
+
16
+ class UserCreate(schemas.BaseUserCreate, JupyterUser):
17
+ pass
18
+
19
+
20
+ class UserUpdate(schemas.BaseUserUpdate, JupyterUser):
21
+ pass
File without changes
@@ -0,0 +1,255 @@
1
+ import contextlib
2
+ import json
3
+ import random
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends
8
+ from jupyverse_api.app import App
9
+ from jupyverse_api.auth import Auth
10
+ from jupyverse_api.frontend import FrontendConfig
11
+ from sqlalchemy import select # type: ignore
12
+
13
+ from jupyverse_api import Router
14
+
15
+ from .backends import get_backend
16
+ from .config import _AuthConfig
17
+ from .db import get_db
18
+ from .models import UserCreate, UserRead, UserUpdate
19
+
20
+
21
+ def auth_factory(
22
+ app: App,
23
+ auth_config: _AuthConfig,
24
+ frontend_config: FrontendConfig,
25
+ ):
26
+ db = get_db(auth_config)
27
+ backend = get_backend(auth_config, frontend_config, db)
28
+
29
+ get_async_session_context = contextlib.asynccontextmanager(db.get_async_session)
30
+ get_user_db_context = contextlib.asynccontextmanager(db.get_user_db)
31
+ get_user_manager_context = contextlib.asynccontextmanager(backend.get_user_manager)
32
+
33
+ @contextlib.asynccontextmanager
34
+ async def _get_user_manager():
35
+ async with get_async_session_context() as session:
36
+ async with get_user_db_context(session) as user_db:
37
+ async with get_user_manager_context(user_db) as user_manager:
38
+ yield user_manager
39
+
40
+ async def create_user(**kwargs):
41
+ async with _get_user_manager() as user_manager:
42
+ await user_manager.create(UserCreate(**kwargs))
43
+
44
+ async def update_user(user, **kwargs):
45
+ async with _get_user_manager() as user_manager:
46
+ await user_manager.update(UserUpdate(**kwargs), user)
47
+
48
+ async def get_user_by_email(user_email):
49
+ async with _get_user_manager() as user_manager:
50
+ return await user_manager.get_by_email(user_email)
51
+
52
+ class _Auth(Auth, Router):
53
+ def __init__(self) -> None:
54
+ super().__init__(app)
55
+
56
+ self.db = db
57
+
58
+ router = APIRouter()
59
+
60
+ @router.get("/auth/users")
61
+ async def get_users(
62
+ user: UserRead = Depends(backend.current_user(permissions={"admin": ["read"]})),
63
+ ):
64
+ async with db.async_session_maker() as session:
65
+ statement = select(db.User)
66
+ users = (await session.execute(statement)).unique().all()
67
+ return [usr.User for usr in users if usr.User.is_active]
68
+
69
+ @router.get("/api/me")
70
+ async def get_api_me(
71
+ permissions: str | None = None,
72
+ user: UserRead = Depends(backend.current_user()),
73
+ update_user=Depends(backend.update_user),
74
+ ):
75
+ checked_permissions: dict[str, list[str]] = {}
76
+ if permissions is None:
77
+ permissions = "{}"
78
+ else:
79
+ permissions = permissions.replace("'", '"')
80
+ permissions_dict = json.loads(permissions)
81
+ if permissions_dict:
82
+ user_permissions = user.permissions
83
+ for resource, actions in permissions_dict.items():
84
+ user_resource_permissions = user_permissions.get(resource)
85
+ if user_resource_permissions is None:
86
+ continue
87
+ allowed = checked_permissions[resource] = []
88
+ for action in actions:
89
+ if action in user_resource_permissions:
90
+ allowed.append(action)
91
+
92
+ keys = ["username", "name", "display_name", "initials", "avatar_url", "color"]
93
+ identity = {k: getattr(user, k) for k in keys}
94
+ if not identity["name"] and not identity["display_name"]:
95
+ moon = get_anonymous_username()
96
+ identity["name"] = f"Anonymous {moon}"
97
+ identity["display_name"] = f"Anonymous {moon}"
98
+ identity["initials"] = f"A{moon[0]}"
99
+ await update_user(
100
+ dict(
101
+ name=identity["name"],
102
+ display_name=identity["display_name"],
103
+ permissions=checked_permissions,
104
+ )
105
+ )
106
+ return {
107
+ "identity": identity,
108
+ "permissions": checked_permissions,
109
+ }
110
+
111
+ # redefine GET /me because we want our current_user dependency
112
+ # it is first defined in users_router and so it wins over the one in
113
+ # fapi_users.get_users_router
114
+ users_router = APIRouter()
115
+
116
+ @users_router.get("/me")
117
+ async def get_me(
118
+ user: UserRead = Depends(backend.current_user(permissions={"admin": ["read"]})),
119
+ ):
120
+ return user
121
+
122
+ users_router.include_router(backend.fapi_users.get_users_router(UserRead, UserUpdate))
123
+
124
+ # Cookie based auth login and logout
125
+ self.include_router(
126
+ backend.fapi_users.get_auth_router(backend.cookie_authentication), prefix="/auth"
127
+ )
128
+ self.include_router(
129
+ backend.fapi_users.get_register_router(UserRead, UserCreate),
130
+ prefix="/auth",
131
+ dependencies=[Depends(backend.current_user(permissions={"admin": ["write"]}))],
132
+ )
133
+ self.include_router(users_router, prefix="/auth/user")
134
+
135
+ # GitHub OAuth register router
136
+ self.include_router(
137
+ backend.fapi_users.get_oauth_router(
138
+ backend.github_authentication, backend.github_cookie_authentication, db.secret
139
+ ),
140
+ prefix="/auth/github",
141
+ )
142
+ self.include_router(router)
143
+
144
+ self.create_user = create_user
145
+ self.__update_user = update_user
146
+ self.get_user_by_email = get_user_by_email
147
+
148
+ async def _update_user(self, user, **kwargs):
149
+ return await self.__update_user(user, **kwargs)
150
+
151
+ def current_user(self, permissions: dict[str, list[str]] | None = None) -> Callable:
152
+ return backend.current_user(permissions)
153
+
154
+ async def update_user(self, update_user=Depends(backend.update_user)) -> Callable:
155
+ return update_user
156
+
157
+ def websocket_auth(
158
+ self,
159
+ permissions: dict[str, list[str]] | None = None,
160
+ ) -> Callable[[Any], Awaitable[tuple[Any, dict[str, list[str]] | None] | None]]:
161
+ return backend.websocket_auth(permissions)
162
+
163
+ return _Auth()
164
+
165
+
166
+ # From https://en.wikipedia.org/wiki/Moons_of_Jupiter
167
+ moons_of_jupyter = (
168
+ "Metis",
169
+ "Adrastea",
170
+ "Amalthea",
171
+ "Thebe",
172
+ "Io",
173
+ "Europa",
174
+ "Ganymede",
175
+ "Callisto",
176
+ "Themisto",
177
+ "Leda",
178
+ "Ersa",
179
+ "Pandia",
180
+ "Himalia",
181
+ "Lysithea",
182
+ "Elara",
183
+ "Dia",
184
+ "Carpo",
185
+ "Valetudo",
186
+ "Euporie",
187
+ "Eupheme",
188
+ # 'S/2003 J 18',
189
+ # 'S/2010 J 2',
190
+ "Helike",
191
+ # 'S/2003 J 16',
192
+ # 'S/2003 J 2',
193
+ "Euanthe",
194
+ # 'S/2017 J 7',
195
+ "Hermippe",
196
+ "Praxidike",
197
+ "Thyone",
198
+ "Thelxinoe",
199
+ # 'S/2017 J 3',
200
+ "Ananke",
201
+ "Mneme",
202
+ # 'S/2016 J 1',
203
+ "Orthosie",
204
+ "Harpalyke",
205
+ "Iocaste",
206
+ # 'S/2017 J 9',
207
+ # 'S/2003 J 12',
208
+ # 'S/2003 J 4',
209
+ "Erinome",
210
+ "Aitne",
211
+ "Herse",
212
+ "Taygete",
213
+ # 'S/2017 J 2',
214
+ # 'S/2017 J 6',
215
+ "Eukelade",
216
+ "Carme",
217
+ # 'S/2003 J 19',
218
+ "Isonoe",
219
+ # 'S/2003 J 10',
220
+ "Autonoe",
221
+ "Philophrosyne",
222
+ "Cyllene",
223
+ "Pasithee",
224
+ # 'S/2010 J 1',
225
+ "Pasiphae",
226
+ "Sponde",
227
+ # 'S/2017 J 8',
228
+ "Eurydome",
229
+ # 'S/2017 J 5',
230
+ "Kalyke",
231
+ "Hegemone",
232
+ "Kale",
233
+ "Kallichore",
234
+ # 'S/2011 J 1',
235
+ # 'S/2017 J 1',
236
+ "Chaldene",
237
+ "Arche",
238
+ "Eirene",
239
+ "Kore",
240
+ # 'S/2011 J 2',
241
+ # 'S/2003 J 9',
242
+ "Megaclite",
243
+ "Aoede",
244
+ # 'S/2003 J 23',
245
+ "Callirrhoe",
246
+ "Sinope",
247
+ )
248
+
249
+
250
+ def get_anonymous_username() -> str:
251
+ """
252
+ Get a random user-name based on the moons of Jupyter.
253
+ This function returns names like "Anonymous Io" or "Anonymous Metis".
254
+ """
255
+ return moons_of_jupyter[random.randint(0, len(moons_of_jupyter) - 1)]