zndraw-auth 0.1.0__py3-none-any.whl

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,74 @@
1
+ """ZnDraw Auth - Shared authentication for ZnDraw ecosystem.
2
+
3
+ Example usage:
4
+ from zndraw_auth import (
5
+ current_active_user,
6
+ current_superuser,
7
+ fastapi_users,
8
+ auth_backend,
9
+ get_async_session,
10
+ create_db_and_tables,
11
+ User,
12
+ UserRead,
13
+ UserCreate,
14
+ )
15
+
16
+ # In your FastAPI app:
17
+ app.include_router(
18
+ fastapi_users.get_auth_router(auth_backend),
19
+ prefix="/auth/jwt",
20
+ tags=["auth"],
21
+ )
22
+
23
+ @app.get("/protected")
24
+ async def protected(user: User = Depends(current_active_user)):
25
+ return {"user_id": str(user.id)}
26
+ """
27
+
28
+ from zndraw_auth.db import (
29
+ Base,
30
+ User,
31
+ create_db_and_tables,
32
+ get_async_session,
33
+ get_user_db,
34
+ )
35
+ from zndraw_auth.schemas import UserCreate, UserRead, UserUpdate
36
+ from zndraw_auth.settings import AuthSettings, get_auth_settings
37
+ from zndraw_auth.users import (
38
+ UserManager,
39
+ auth_backend,
40
+ current_active_user,
41
+ current_optional_user,
42
+ current_superuser,
43
+ fastapi_users,
44
+ get_user_manager,
45
+ )
46
+
47
+ __all__ = [
48
+ # SQLAlchemy Base (for extending with your own models)
49
+ "Base",
50
+ # User model
51
+ "User",
52
+ # Database
53
+ "create_db_and_tables",
54
+ "get_async_session",
55
+ "get_user_db",
56
+ # Schemas
57
+ "UserCreate",
58
+ "UserRead",
59
+ "UserUpdate",
60
+ # Settings
61
+ "AuthSettings",
62
+ "get_auth_settings",
63
+ # User manager
64
+ "UserManager",
65
+ "get_user_manager",
66
+ # Auth backend
67
+ "auth_backend",
68
+ # FastAPIUsers instance
69
+ "fastapi_users",
70
+ # Dependencies for Depends()
71
+ "current_active_user",
72
+ "current_superuser",
73
+ "current_optional_user",
74
+ ]
zndraw_auth/db.py ADDED
@@ -0,0 +1,80 @@
1
+ """Database models and session management."""
2
+
3
+ import uuid
4
+ from collections.abc import AsyncGenerator
5
+ from functools import lru_cache
6
+ from typing import Annotated
7
+
8
+ from fastapi import Depends
9
+ from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
10
+ from sqlalchemy.ext.asyncio import (
11
+ AsyncEngine,
12
+ AsyncSession,
13
+ async_sessionmaker,
14
+ create_async_engine,
15
+ )
16
+ from sqlalchemy.orm import DeclarativeBase
17
+
18
+ from zndraw_auth.settings import AuthSettings, get_auth_settings
19
+
20
+
21
+ class Base(DeclarativeBase):
22
+ """SQLAlchemy declarative base."""
23
+
24
+ pass
25
+
26
+
27
+ class User(SQLAlchemyBaseUserTableUUID, Base):
28
+ """User model for authentication.
29
+
30
+ Inherits from fastapi-users base which provides:
31
+ - id: UUID (primary key)
32
+ - email: str (unique, indexed)
33
+ - hashed_password: str
34
+ - is_active: bool (default True)
35
+ - is_superuser: bool (default False)
36
+ - is_verified: bool (default False)
37
+ """
38
+
39
+ pass
40
+
41
+
42
+ @lru_cache
43
+ def get_engine(database_url: str) -> AsyncEngine:
44
+ """Get or create the async engine (cached by URL)."""
45
+ return create_async_engine(database_url, echo=False)
46
+
47
+
48
+ @lru_cache
49
+ def get_session_maker(database_url: str) -> async_sessionmaker[AsyncSession]:
50
+ """Get or create the session maker (cached by URL)."""
51
+ engine = get_engine(database_url)
52
+ return async_sessionmaker(engine, expire_on_commit=False)
53
+
54
+
55
+ async def create_db_and_tables(settings: AuthSettings | None = None) -> None:
56
+ """Create all database tables.
57
+
58
+ Call this in your app's lifespan or startup.
59
+ """
60
+ if settings is None:
61
+ settings = get_auth_settings()
62
+ engine = get_engine(settings.database_url)
63
+ async with engine.begin() as conn:
64
+ await conn.run_sync(Base.metadata.create_all)
65
+
66
+
67
+ async def get_async_session(
68
+ settings: Annotated[AuthSettings, Depends(get_auth_settings)],
69
+ ) -> AsyncGenerator[AsyncSession, None]:
70
+ """FastAPI dependency that yields an async database session."""
71
+ session_maker = get_session_maker(settings.database_url)
72
+ async with session_maker() as session:
73
+ yield session
74
+
75
+
76
+ async def get_user_db(
77
+ session: Annotated[AsyncSession, Depends(get_async_session)],
78
+ ) -> AsyncGenerator[SQLAlchemyUserDatabase[User, uuid.UUID], None]:
79
+ """FastAPI dependency that yields the user database adapter."""
80
+ yield SQLAlchemyUserDatabase[User, uuid.UUID](session, User)
zndraw_auth/schemas.py ADDED
@@ -0,0 +1,23 @@
1
+ """Pydantic schemas for user operations."""
2
+
3
+ import uuid
4
+
5
+ from fastapi_users import schemas
6
+
7
+
8
+ class UserRead(schemas.BaseUser[uuid.UUID]):
9
+ """Schema for reading user data (responses)."""
10
+
11
+ pass
12
+
13
+
14
+ class UserCreate(schemas.BaseUserCreate):
15
+ """Schema for creating a new user."""
16
+
17
+ pass
18
+
19
+
20
+ class UserUpdate(schemas.BaseUserUpdate):
21
+ """Schema for updating an existing user."""
22
+
23
+ pass
@@ -0,0 +1,36 @@
1
+ """Configuration settings for zndraw-auth."""
2
+
3
+ from functools import lru_cache
4
+
5
+ from pydantic import SecretStr
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class AuthSettings(BaseSettings):
10
+ """Authentication settings loaded from environment variables.
11
+
12
+ All settings can be overridden with ZNDRAW_AUTH_ prefix.
13
+ Example: ZNDRAW_AUTH_SECRET_KEY=your-secret-key
14
+ """
15
+
16
+ model_config = SettingsConfigDict(
17
+ env_prefix="ZNDRAW_AUTH_",
18
+ env_file=".env",
19
+ extra="ignore",
20
+ )
21
+
22
+ # JWT settings
23
+ secret_key: SecretStr = SecretStr("CHANGE-ME-IN-PRODUCTION")
24
+ token_lifetime_seconds: int = 3600 # 1 hour
25
+
26
+ # Database
27
+ database_url: str = "sqlite+aiosqlite:///./zndraw_auth.db"
28
+
29
+ # Password reset / verification tokens
30
+ reset_password_token_secret: SecretStr = SecretStr("CHANGE-ME-RESET")
31
+ verification_token_secret: SecretStr = SecretStr("CHANGE-ME-VERIFY")
32
+
33
+
34
+ @lru_cache
35
+ def get_auth_settings() -> AuthSettings:
36
+ return AuthSettings()
zndraw_auth/users.py ADDED
@@ -0,0 +1,139 @@
1
+ """FastAPI-Users configuration and exported dependencies.
2
+
3
+ This module exports the key dependencies that other packages should import:
4
+ - current_active_user: Depends() for authenticated active user
5
+ - current_superuser: Depends() for authenticated superuser
6
+ - fastapi_users: The FastAPIUsers instance for including routers
7
+ - auth_backend: The JWT authentication backend
8
+
9
+ Example usage in other packages:
10
+ from zndraw_auth import current_active_user, User
11
+
12
+ @router.get("/protected")
13
+ async def protected_route(user: User = Depends(current_active_user)):
14
+ return {"user_id": str(user.id)}
15
+ """
16
+
17
+ import uuid
18
+ from collections.abc import AsyncGenerator
19
+ from typing import Annotated
20
+
21
+ from fastapi import Depends, Request
22
+ from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
23
+ from fastapi_users.authentication import (
24
+ AuthenticationBackend,
25
+ BearerTransport,
26
+ JWTStrategy,
27
+ )
28
+ from fastapi_users.db import SQLAlchemyUserDatabase
29
+
30
+ from zndraw_auth.db import User, get_user_db
31
+ from zndraw_auth.settings import AuthSettings, get_auth_settings
32
+
33
+ # --- User Manager ---
34
+
35
+
36
+ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
37
+ """Custom user manager with lifecycle hooks.
38
+
39
+ Token secrets are set via dependency injection in get_user_manager.
40
+ """
41
+
42
+ reset_password_token_secret: str
43
+ verification_token_secret: str
44
+
45
+ async def on_after_register(
46
+ self, user: User, request: Request | None = None
47
+ ) -> None:
48
+ """Called after successful registration."""
49
+ print(f"User {user.id} has registered.")
50
+
51
+ async def on_after_forgot_password(
52
+ self, user: User, token: str, request: Request | None = None
53
+ ) -> None:
54
+ """Called after password reset requested."""
55
+ print(f"User {user.id} forgot password. Reset token: {token}")
56
+
57
+ async def on_after_request_verify(
58
+ self, user: User, token: str, request: Request | None = None
59
+ ) -> None:
60
+ """Called after verification requested."""
61
+ print(f"Verification requested for {user.id}. Token: {token}")
62
+
63
+
64
+ async def get_user_manager(
65
+ user_db: Annotated[SQLAlchemyUserDatabase[User, uuid.UUID], Depends(get_user_db)],
66
+ settings: Annotated[AuthSettings, Depends(get_auth_settings)],
67
+ ) -> AsyncGenerator[UserManager, None]:
68
+ """FastAPI dependency that yields the user manager."""
69
+ manager = UserManager(user_db)
70
+ manager.reset_password_token_secret = (
71
+ settings.reset_password_token_secret.get_secret_value()
72
+ )
73
+ manager.verification_token_secret = (
74
+ settings.verification_token_secret.get_secret_value()
75
+ )
76
+ yield manager
77
+
78
+
79
+ # --- Authentication Backend ---
80
+
81
+
82
+ bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
83
+
84
+
85
+ def get_jwt_strategy(
86
+ settings: Annotated[AuthSettings, Depends(get_auth_settings)],
87
+ ) -> JWTStrategy[User, uuid.UUID]:
88
+ """Get JWT strategy with settings."""
89
+ return JWTStrategy(
90
+ secret=settings.secret_key.get_secret_value(),
91
+ lifetime_seconds=settings.token_lifetime_seconds,
92
+ )
93
+
94
+
95
+ auth_backend = AuthenticationBackend(
96
+ name="jwt",
97
+ transport=bearer_transport,
98
+ get_strategy=get_jwt_strategy,
99
+ )
100
+
101
+
102
+ # --- FastAPI Users Instance ---
103
+
104
+
105
+ fastapi_users = FastAPIUsers[User, uuid.UUID](
106
+ get_user_manager,
107
+ [auth_backend],
108
+ )
109
+
110
+
111
+ # --- Exported Dependencies ---
112
+ # These are the main exports that other packages should use
113
+
114
+ current_active_user = fastapi_users.current_user(active=True)
115
+ """Dependency for routes requiring an authenticated active user.
116
+
117
+ Usage:
118
+ @router.get("/protected")
119
+ async def route(user: User = Depends(current_active_user)):
120
+ ...
121
+ """
122
+
123
+ current_superuser = fastapi_users.current_user(active=True, superuser=True)
124
+ """Dependency for routes requiring superuser privileges.
125
+
126
+ Usage:
127
+ @router.get("/admin")
128
+ async def route(user: User = Depends(current_superuser)):
129
+ ...
130
+ """
131
+
132
+ current_optional_user = fastapi_users.current_user(active=True, optional=True)
133
+ """Dependency for routes with optional authentication.
134
+
135
+ Usage:
136
+ @router.get("/public")
137
+ async def route(user: User | None = Depends(current_optional_user)):
138
+ ...
139
+ """
@@ -0,0 +1,267 @@
1
+ Metadata-Version: 2.3
2
+ Name: zndraw-auth
3
+ Version: 0.1.0
4
+ Summary: Shared authentication for ZnDraw using fastapi-users
5
+ Requires-Dist: fastapi>=0.128.0
6
+ Requires-Dist: fastapi-users[sqlalchemy]>=14.0.0
7
+ Requires-Dist: pydantic-settings>=2.0.0
8
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.0
9
+ Requires-Dist: aiosqlite>=0.19.0
10
+ Requires-Dist: pytest>=8.0.0 ; extra == 'dev'
11
+ Requires-Dist: pytest-asyncio>=0.23.0 ; extra == 'dev'
12
+ Requires-Dist: httpx>=0.27.0 ; extra == 'dev'
13
+ Requires-Dist: ruff>=0.8.0 ; extra == 'dev'
14
+ Requires-Dist: mypy>=1.0.0 ; extra == 'dev'
15
+ Requires-Python: >=3.11
16
+ Provides-Extra: dev
17
+ Description-Content-Type: text/markdown
18
+
19
+ # zndraw-auth
20
+
21
+ Shared authentication package for the ZnDraw ecosystem using [fastapi-users](https://fastapi-users.github.io/fastapi-users/).
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install zndraw-auth
27
+ # or with uv
28
+ uv add zndraw-auth
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ ```python
34
+ from contextlib import asynccontextmanager
35
+ from fastapi import Depends, FastAPI
36
+
37
+ from zndraw_auth import (
38
+ User,
39
+ UserCreate,
40
+ UserRead,
41
+ auth_backend,
42
+ create_db_and_tables,
43
+ current_active_user,
44
+ fastapi_users,
45
+ )
46
+
47
+
48
+ @asynccontextmanager
49
+ async def lifespan(app: FastAPI):
50
+ await create_db_and_tables()
51
+ yield
52
+
53
+
54
+ app = FastAPI(lifespan=lifespan)
55
+
56
+ # Include auth routers
57
+ app.include_router(
58
+ fastapi_users.get_auth_router(auth_backend),
59
+ prefix="/auth/jwt",
60
+ tags=["auth"],
61
+ )
62
+ app.include_router(
63
+ fastapi_users.get_register_router(UserRead, UserCreate),
64
+ prefix="/auth",
65
+ tags=["auth"],
66
+ )
67
+
68
+
69
+ @app.get("/protected")
70
+ async def protected_route(user: User = Depends(current_active_user)):
71
+ return {"message": f"Hello {user.email}!"}
72
+ ```
73
+
74
+ ## Extending with Custom Models (e.g., zndraw-joblib)
75
+
76
+ Other packages can import `Base` and `get_async_session` to define models that share the same database and have foreign key relationships to `User`.
77
+
78
+ ### Example: Adding a Job model in zndraw-joblib
79
+
80
+ ```python
81
+ # zndraw_joblib/models.py
82
+ import uuid
83
+ from typing import TYPE_CHECKING
84
+
85
+ from sqlalchemy import ForeignKey, String
86
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
87
+
88
+ from zndraw_auth import Base
89
+
90
+ if TYPE_CHECKING:
91
+ from zndraw_auth import User
92
+
93
+
94
+ class Job(Base):
95
+ """A compute job owned by a user."""
96
+
97
+ __tablename__ = "job"
98
+
99
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
100
+ name: Mapped[str] = mapped_column(String(255))
101
+ status: Mapped[str] = mapped_column(String(50), default="pending")
102
+
103
+ # Foreign key to User from zndraw-auth (cascade delete when user is deleted)
104
+ user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id", ondelete="cascade"))
105
+
106
+ # Relationship (optional, for ORM navigation)
107
+ user: Mapped["User"] = relationship("User", lazy="selectin")
108
+ ```
109
+
110
+ ### Example: Using the shared session in endpoints
111
+
112
+ ```python
113
+ # zndraw_joblib/routes.py
114
+ from typing import Annotated
115
+ from uuid import UUID
116
+
117
+ from fastapi import APIRouter, Depends, HTTPException
118
+ from sqlalchemy import select
119
+ from sqlalchemy.ext.asyncio import AsyncSession
120
+
121
+ from zndraw_auth import User, current_active_user, get_async_session
122
+
123
+ from .models import Job
124
+
125
+ router = APIRouter(prefix="/jobs", tags=["jobs"])
126
+
127
+
128
+ @router.post("/")
129
+ async def create_job(
130
+ name: str,
131
+ user: Annotated[User, Depends(current_active_user)],
132
+ session: Annotated[AsyncSession, Depends(get_async_session)],
133
+ ):
134
+ """Create a new job for the current user."""
135
+ job = Job(name=name, user_id=user.id)
136
+ session.add(job)
137
+ await session.commit()
138
+ await session.refresh(job)
139
+ return {"id": str(job.id), "name": job.name, "status": job.status}
140
+
141
+
142
+ @router.get("/")
143
+ async def list_jobs(
144
+ user: Annotated[User, Depends(current_active_user)],
145
+ session: Annotated[AsyncSession, Depends(get_async_session)],
146
+ ):
147
+ """List all jobs for the current user."""
148
+ result = await session.execute(
149
+ select(Job).where(Job.user_id == user.id)
150
+ )
151
+ jobs = result.scalars().all()
152
+ return [{"id": str(j.id), "name": j.name, "status": j.status} for j in jobs]
153
+
154
+
155
+ @router.get("/{job_id}")
156
+ async def get_job(
157
+ job_id: UUID,
158
+ user: Annotated[User, Depends(current_active_user)],
159
+ session: Annotated[AsyncSession, Depends(get_async_session)],
160
+ ):
161
+ """Get a specific job (must belong to current user)."""
162
+ result = await session.execute(
163
+ select(Job).where(Job.id == job_id, Job.user_id == user.id)
164
+ )
165
+ job = result.scalar_one_or_none()
166
+ if not job:
167
+ raise HTTPException(status_code=404, detail="Job not found")
168
+ return {"id": str(job.id), "name": job.name, "status": job.status}
169
+ ```
170
+
171
+ ### Example: App setup with multiple routers
172
+
173
+ ```python
174
+ # main.py (in zndraw-fastapi or combined app)
175
+ from contextlib import asynccontextmanager
176
+
177
+ from fastapi import FastAPI
178
+
179
+ from zndraw_auth import (
180
+ UserCreate,
181
+ UserRead,
182
+ auth_backend,
183
+ create_db_and_tables,
184
+ fastapi_users,
185
+ )
186
+ from zndraw_joblib.routes import router as jobs_router
187
+
188
+
189
+ @asynccontextmanager
190
+ async def lifespan(app: FastAPI):
191
+ # Creates tables for User AND Job (all models using Base)
192
+ await create_db_and_tables()
193
+ yield
194
+
195
+
196
+ app = FastAPI(lifespan=lifespan)
197
+
198
+ # Auth routes from zndraw-auth
199
+ app.include_router(
200
+ fastapi_users.get_auth_router(auth_backend),
201
+ prefix="/auth/jwt",
202
+ tags=["auth"],
203
+ )
204
+ app.include_router(
205
+ fastapi_users.get_register_router(UserRead, UserCreate),
206
+ prefix="/auth",
207
+ tags=["auth"],
208
+ )
209
+
210
+ # Job routes from zndraw-joblib
211
+ app.include_router(jobs_router)
212
+ ```
213
+
214
+ ## Configuration
215
+
216
+ Settings are loaded from environment variables with the `ZNDRAW_AUTH_` prefix:
217
+
218
+ | Variable | Default | Description |
219
+ |----------|---------|-------------|
220
+ | `ZNDRAW_AUTH_SECRET_KEY` | `CHANGE-ME-IN-PRODUCTION` | JWT signing secret |
221
+ | `ZNDRAW_AUTH_TOKEN_LIFETIME_SECONDS` | `3600` | JWT token lifetime |
222
+ | `ZNDRAW_AUTH_DATABASE_URL` | `sqlite+aiosqlite:///./zndraw_auth.db` | Database connection URL |
223
+ | `ZNDRAW_AUTH_RESET_PASSWORD_TOKEN_SECRET` | `CHANGE-ME-RESET` | Password reset token secret |
224
+ | `ZNDRAW_AUTH_VERIFICATION_TOKEN_SECRET` | `CHANGE-ME-VERIFY` | Email verification token secret |
225
+
226
+ ## Exports
227
+
228
+ ```python
229
+ from zndraw_auth import (
230
+ # SQLAlchemy Base (for extending with your own models)
231
+ Base,
232
+
233
+ # User model
234
+ User,
235
+
236
+ # Database utilities
237
+ create_db_and_tables,
238
+ get_async_session,
239
+ get_user_db,
240
+
241
+ # Pydantic schemas
242
+ UserCreate,
243
+ UserRead,
244
+ UserUpdate,
245
+
246
+ # Settings
247
+ AuthSettings,
248
+ get_auth_settings,
249
+
250
+ # User manager (for custom lifecycle hooks)
251
+ UserManager,
252
+ get_user_manager,
253
+
254
+ # FastAPIUsers instance (for including routers)
255
+ fastapi_users,
256
+ auth_backend,
257
+
258
+ # Dependencies for Depends()
259
+ current_active_user, # Requires authenticated active user
260
+ current_superuser, # Requires superuser
261
+ current_optional_user, # User | None (optional auth)
262
+ )
263
+ ```
264
+
265
+ ## License
266
+
267
+ MIT
@@ -0,0 +1,8 @@
1
+ zndraw_auth/__init__.py,sha256=kHAQDlr6uxJCFFeIrJQWSfRqij6g8cSBj0iAfkqX1P0,1651
2
+ zndraw_auth/db.py,sha256=sxUZ_lfOP_DfZoSsTUhMPaomSF6MPLPD1NuEa_N4hLk,2383
3
+ zndraw_auth/schemas.py,sha256=lG1c-donAxTqqyQ1-p_GqsNi9DIN2ryESbjVL25R4oQ,399
4
+ zndraw_auth/settings.py,sha256=pNXX2-NlvBMobFiGytrEmDZ2EB65BUrvwKCXxrbYp8I,1012
5
+ zndraw_auth/users.py,sha256=LRI4LoY2xXUW24yQZIfa-v6oq1FcHovCEQDZIa2bz_0,4137
6
+ zndraw_auth-0.1.0.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
7
+ zndraw_auth-0.1.0.dist-info/METADATA,sha256=_iHPAJGDovbkX86AOtt8Jgsr_nTR71BUEnq1gp7rloc,6865
8
+ zndraw_auth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any