fastapi-auth-starter 0.1.3__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.
Files changed (44) hide show
  1. fastapi_auth_starter/__init__.py +7 -0
  2. fastapi_auth_starter/cli.py +326 -0
  3. fastapi_auth_starter-0.1.3.data/data/README.md +247 -0
  4. fastapi_auth_starter-0.1.3.data/data/alembic/README +1 -0
  5. fastapi_auth_starter-0.1.3.data/data/alembic/env.py +100 -0
  6. fastapi_auth_starter-0.1.3.data/data/alembic/script.py.mako +28 -0
  7. fastapi_auth_starter-0.1.3.data/data/alembic/versions/279c472f4fd8_add_user_table.py +42 -0
  8. fastapi_auth_starter-0.1.3.data/data/alembic/versions/5f062b3648fa_change_user_id_from_uuid_to_string_for_.py +38 -0
  9. fastapi_auth_starter-0.1.3.data/data/alembic/versions/8d275132562b_create_tasks_table.py +44 -0
  10. fastapi_auth_starter-0.1.3.data/data/alembic.ini +150 -0
  11. fastapi_auth_starter-0.1.3.data/data/app/__init__.py +5 -0
  12. fastapi_auth_starter-0.1.3.data/data/app/api/__init__.py +4 -0
  13. fastapi_auth_starter-0.1.3.data/data/app/api/v1/__init__.py +4 -0
  14. fastapi_auth_starter-0.1.3.data/data/app/api/v1/api.py +21 -0
  15. fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/__init__.py +4 -0
  16. fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/auth.py +513 -0
  17. fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/health.py +50 -0
  18. fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/task.py +182 -0
  19. fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/user.py +144 -0
  20. fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/__init__.py +8 -0
  21. fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/auth.py +198 -0
  22. fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/task.py +61 -0
  23. fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/user.py +96 -0
  24. fastapi_auth_starter-0.1.3.data/data/app/core/__init__.py +4 -0
  25. fastapi_auth_starter-0.1.3.data/data/app/core/config.py +107 -0
  26. fastapi_auth_starter-0.1.3.data/data/app/core/database.py +106 -0
  27. fastapi_auth_starter-0.1.3.data/data/app/core/dependencies.py +148 -0
  28. fastapi_auth_starter-0.1.3.data/data/app/core/exceptions.py +7 -0
  29. fastapi_auth_starter-0.1.3.data/data/app/db/__init__.py +4 -0
  30. fastapi_auth_starter-0.1.3.data/data/app/main.py +91 -0
  31. fastapi_auth_starter-0.1.3.data/data/app/models/__init__.py +14 -0
  32. fastapi_auth_starter-0.1.3.data/data/app/models/task.py +56 -0
  33. fastapi_auth_starter-0.1.3.data/data/app/models/user.py +45 -0
  34. fastapi_auth_starter-0.1.3.data/data/app/services/__init__.py +8 -0
  35. fastapi_auth_starter-0.1.3.data/data/app/services/auth.py +405 -0
  36. fastapi_auth_starter-0.1.3.data/data/app/services/task.py +165 -0
  37. fastapi_auth_starter-0.1.3.data/data/app/services/user.py +108 -0
  38. fastapi_auth_starter-0.1.3.data/data/pyproject.toml +77 -0
  39. fastapi_auth_starter-0.1.3.data/data/runtime.txt +2 -0
  40. fastapi_auth_starter-0.1.3.data/data/vercel.json +19 -0
  41. fastapi_auth_starter-0.1.3.dist-info/METADATA +283 -0
  42. fastapi_auth_starter-0.1.3.dist-info/RECORD +44 -0
  43. fastapi_auth_starter-0.1.3.dist-info/WHEEL +4 -0
  44. fastapi_auth_starter-0.1.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,405 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Optional
4
+ import httpx
5
+ from authlib.jose import jwt, JsonWebKey
6
+ from authlib.jose.errors import DecodeError, ExpiredTokenError, InvalidClaimError, BadSignatureError
7
+ from workos import WorkOSClient
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ from sqlalchemy import select
10
+ from app.api.v1.schemas.auth import ForgotPasswordRequest, ForgotPasswordResponse, LoginResponse, RefreshTokenResponse, SignupResponse, WorkOSAuthorizationRequest, WorkOSLoginRequest, WorkOSRefreshTokenRequest, WorkOSResetPasswordRequest, WorkOsVerifyEmailRequest, WorkOSUserResponse
11
+ from app.core.config import settings
12
+ from app.models.user import User
13
+
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ class AuthService:
19
+ def __init__(self):
20
+ self.workos_client = WorkOSClient(
21
+ api_key=settings.WORKOS_API_KEY,
22
+ client_id=settings.WORKOS_CLIENT_ID
23
+ )
24
+ # Cache JWKS to avoid repeated fetches (cache for 1 hour)
25
+ self._jwks_cache: Optional[dict] = None
26
+ self._jwks_cache_expiry: Optional[float] = None
27
+
28
+ async def verify_email(self, verify_email_request: WorkOsVerifyEmailRequest):
29
+ # Offload synchronous WorkOS call to thread pool to avoid blocking event loop
30
+ # Reference: https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread
31
+ response = await asyncio.to_thread(
32
+ self.workos_client.user_management.authenticate_with_email_verification,
33
+ code=verify_email_request.code,
34
+ pending_authentication_token=verify_email_request.pending_authentication_token,
35
+ ip_address=verify_email_request.ip_address,
36
+ user_agent=verify_email_request.user_agent
37
+ )
38
+ return response
39
+
40
+ async def login(self, login_request: WorkOSLoginRequest) -> LoginResponse:
41
+ # Offload synchronous WorkOS call to thread pool to avoid blocking event loop
42
+ response = await asyncio.to_thread(
43
+ self.workos_client.user_management.authenticate_with_password,
44
+ email=login_request.email,
45
+ password=login_request.password,
46
+ ip_address=login_request.ip_address,
47
+ user_agent=login_request.user_agent
48
+ )
49
+ return LoginResponse(
50
+ user=response.user,
51
+ organization_id=response.organization_id,
52
+ access_token=response.access_token,
53
+ refresh_token=response.refresh_token
54
+ )
55
+
56
+ async def signup(
57
+ self,
58
+ db: AsyncSession,
59
+ email: str,
60
+ password: str,
61
+ first_name: Optional[str] = None,
62
+ last_name: Optional[str] = None
63
+ ) -> SignupResponse:
64
+ """
65
+ Sign up a new user.
66
+
67
+ Creates the user in WorkOS and saves to database.
68
+ User must verify their email before they can login.
69
+
70
+ Args:
71
+ db: Database session
72
+ email: User email
73
+ password: User password
74
+ first_name: Optional first name
75
+ last_name: Optional last name
76
+
77
+ Returns:
78
+ SignupResponse with user info (no tokens - email verification required)
79
+
80
+ Raises:
81
+ IntegrityError: If user already exists in database (email conflict)
82
+ BadRequestException: If user creation fails in WorkOS (e.g., email already exists)
83
+ """
84
+ # Check if user already exists in database BEFORE creating in WorkOS
85
+ # This prevents creating orphaned users in WorkOS if DB insert fails
86
+ result = await db.execute(select(User).where(User.email == email))
87
+ existing_user = result.scalar_one_or_none()
88
+
89
+ if existing_user:
90
+ logger.warning(f"User already exists in database: {email}")
91
+ # Raise IntegrityError to match database constraint violation behavior
92
+ # This will be caught by the route handler and converted to 409 Conflict
93
+ from sqlalchemy.exc import IntegrityError as SQLIntegrityError
94
+ raise SQLIntegrityError(
95
+ statement="INSERT INTO users",
96
+ params=None,
97
+ orig=Exception("duplicate key value violates unique constraint \"ix_users_email\"")
98
+ )
99
+
100
+ # Create user in WorkOS (only if not in database)
101
+ create_user_payload = {
102
+ "email": email,
103
+ "password": password,
104
+ }
105
+ if first_name:
106
+ create_user_payload["first_name"] = first_name
107
+ if last_name:
108
+ create_user_payload["last_name"] = last_name
109
+
110
+ # Offload synchronous WorkOS call to thread pool
111
+ workos_user = await asyncio.to_thread(
112
+ self.workos_client.user_management.create_user,
113
+ **create_user_payload
114
+ )
115
+
116
+ # Create user in database with error handling
117
+ # If DB insert fails, we need to clean up the WorkOS user to prevent orphaned accounts
118
+ # Reference: https://workos.com/docs/reference/user-management/delete-user
119
+ try:
120
+ user = User(
121
+ id=workos_user.id,
122
+ email=workos_user.email,
123
+ first_name=workos_user.first_name,
124
+ last_name=workos_user.last_name
125
+ )
126
+ db.add(user)
127
+ await db.flush()
128
+ except Exception as db_error:
129
+ # Database operation failed - clean up WorkOS user to prevent orphaned account
130
+ # This prevents users from being locked out if DB insert fails (race condition, connection issue, etc.)
131
+ logger.warning(
132
+ f"Database insert failed after WorkOS user creation for {email}. "
133
+ f"Cleaning up WorkOS user {workos_user.id}. Error: {db_error}"
134
+ )
135
+ try:
136
+ await asyncio.to_thread(
137
+ self.workos_client.user_management.delete_user,
138
+ user_id=workos_user.id
139
+ )
140
+ logger.info(f"Successfully cleaned up WorkOS user {workos_user.id}")
141
+ except Exception as cleanup_error:
142
+ # Log cleanup failure but don't mask the original error
143
+ logger.error(
144
+ f"Failed to clean up WorkOS user {workos_user.id} after DB failure. "
145
+ f"Cleanup error: {cleanup_error}. Original error: {db_error}",
146
+ exc_info=True
147
+ )
148
+ # Re-raise the original database error
149
+ raise
150
+
151
+ logger.info(f"User created: {workos_user.id} ({email})")
152
+
153
+ # Convert WorkOS user to response schema
154
+ user_response = WorkOSUserResponse(
155
+ object=workos_user.object,
156
+ id=workos_user.id,
157
+ email=workos_user.email,
158
+ first_name=workos_user.first_name,
159
+ last_name=workos_user.last_name,
160
+ email_verified=workos_user.email_verified,
161
+ profile_picture_url=workos_user.profile_picture_url,
162
+ created_at=workos_user.created_at,
163
+ updated_at=workos_user.updated_at,
164
+ )
165
+
166
+ return SignupResponse(user=user_response)
167
+
168
+ async def forgot_password(self, forgot_password_request: ForgotPasswordRequest) -> ForgotPasswordResponse:
169
+
170
+ # WorkOS generates token and sends email
171
+ # The email will use the URL you configured in Dashboard → Redirects
172
+ await asyncio.to_thread(
173
+ self.workos_client.user_management.create_password_reset,
174
+ email=forgot_password_request.email
175
+ )
176
+
177
+ # WorkOS automatically sends email with your configured URL
178
+ # The URL will be: your-frontend.com/reset-password?token=...
179
+ # NB: The WorkOS dashboard needs to be updated with the frontend password reset URL
180
+
181
+ # Return generic success message (don't expose token/URL)
182
+ return ForgotPasswordResponse(
183
+ message="If an account exists with this email address, a password reset link has been sent."
184
+ )
185
+
186
+ async def reset_password(self, reset_password_request: WorkOSResetPasswordRequest) -> WorkOSUserResponse:
187
+ """
188
+ Reset a user's password.
189
+
190
+ Args:
191
+ reset_password_request: WorkOSResetPasswordRequest
192
+
193
+ Returns:
194
+ WorkOSUserResponse: User information
195
+ """
196
+ # Offload synchronous WorkOS call to thread pool to avoid blocking event loop
197
+ response = await asyncio.to_thread(
198
+ self.workos_client.user_management.reset_password,
199
+ token=reset_password_request.token,
200
+ new_password=reset_password_request.new_password
201
+ )
202
+ return WorkOSUserResponse(
203
+ object=response.object,
204
+ id=response.id,
205
+ email=response.email,
206
+ first_name=response.first_name,
207
+ last_name=response.last_name,
208
+ email_verified=response.email_verified,
209
+ profile_picture_url=response.profile_picture_url,
210
+ created_at=response.created_at,
211
+ updated_at=response.updated_at,
212
+ )
213
+
214
+ # Generate OAuth2 authorization URL
215
+ async def generate_oauth2_authorization_url(
216
+ self,
217
+ authorization_request: WorkOSAuthorizationRequest
218
+ ) -> str:
219
+ """
220
+ Generate OAuth2 authorization URL.
221
+
222
+ Supports two patterns:
223
+ 1. AuthKit: provider="authkit" → Unified authentication interface
224
+ 2. SSO: connection_id="conn_xxx" → Direct provider connection
225
+
226
+ Args:
227
+ authorization_request: Request containing either provider or connection_id
228
+
229
+ Returns:
230
+ Authorization URL string
231
+ """
232
+ params = {
233
+ "redirect_uri": authorization_request.redirect_uri,
234
+ }
235
+
236
+ # Add state if provided
237
+ if authorization_request.state:
238
+ params["state"] = authorization_request.state
239
+
240
+ # Determine which pattern to use
241
+ if authorization_request.provider:
242
+ # AuthKit pattern
243
+ params["provider"] = authorization_request.provider
244
+ elif authorization_request.connection_id:
245
+ # SSO pattern
246
+ params["connection_id"] = authorization_request.connection_id
247
+
248
+ # Offload synchronous WorkOS call to thread pool to avoid blocking event loop
249
+ authorization_url = await asyncio.to_thread(
250
+ self.workos_client.user_management.get_authorization_url,
251
+ **params
252
+ )
253
+ return authorization_url
254
+
255
+
256
+ async def oauth2_callback(
257
+ self,
258
+ code: str
259
+ ) -> LoginResponse:
260
+ """
261
+ Exchange a OAuth2 code for access token and refresh token.
262
+
263
+ Args:
264
+ code: OAuth2 code
265
+
266
+ Returns:
267
+ LoginResponse: Access token and refresh token
268
+ """
269
+ # Offload synchronous WorkOS call to thread pool to avoid blocking event loop
270
+ response = await asyncio.to_thread(
271
+ self.workos_client.user_management.authenticate_with_code,
272
+ code=code
273
+ )
274
+ return LoginResponse(
275
+ user=response.user,
276
+ organization_id=response.organization_id,
277
+ access_token=response.access_token,
278
+ refresh_token=response.refresh_token
279
+ )
280
+
281
+ async def verify_session(self, access_token: str) -> dict:
282
+ """
283
+ Verify a WorkOS JWT access token with full signature verification.
284
+
285
+ Uses WorkOS JWKS to verify the token signature. This ensures the token
286
+ is authentic and hasn't been tampered with.
287
+
288
+ Reference:
289
+ - https://workos.com/docs/reference/authkit/session-tokens/access-token
290
+ - https://workos.com/docs/reference/authkit/session-tokens/jwks
291
+
292
+ Args:
293
+ access_token: JWT token from WorkOS
294
+
295
+ Returns:
296
+ Dict with user information from verified token:
297
+ - user_id: User ID (sub claim)
298
+ - session_id: Session ID (sid claim)
299
+ - organization_id: Organization ID (org_id claim)
300
+ - role: User role (role claim)
301
+ - roles: Array of roles (roles claim)
302
+ - permissions: Array of permissions (permissions claim)
303
+ - entitlements: Array of entitlements (entitlements claim)
304
+ - exp: Expiration timestamp
305
+ - iat: Issued at timestamp
306
+
307
+ Raises:
308
+ ValueError: If token is invalid, expired, or signature verification fails
309
+ """
310
+ try:
311
+ # Get JWKS URL from WorkOS SDK
312
+ # Reference: https://workos.com/docs/reference/authkit/session-tokens/jwks
313
+ # get_jwks_url() uses the client_id from the WorkOSClient initialization
314
+ jwks_url = await asyncio.to_thread(
315
+ self.workos_client.user_management.get_jwks_url
316
+ )
317
+
318
+ # Fetch JWKS (with caching to avoid repeated API calls)
319
+ current_time = time.time()
320
+ if not self._jwks_cache or (self._jwks_cache_expiry and current_time > self._jwks_cache_expiry):
321
+ logger.debug(f"Fetching JWKS from: {jwks_url}")
322
+ async with httpx.AsyncClient() as client:
323
+ response = await client.get(jwks_url, timeout=10.0)
324
+ response.raise_for_status()
325
+ self._jwks_cache = response.json()
326
+ # Cache for 1 hour (JWKS keys don't change often)
327
+ self._jwks_cache_expiry = current_time + 3600
328
+ logger.debug(f"JWKS fetched and cached. Keys: {len(self._jwks_cache.get('keys', []))}")
329
+
330
+ # Create JWK set from JWKS
331
+ # authlib handles parsing the JWKS and selecting the correct key
332
+ jwk_set = JsonWebKey.import_key_set(self._jwks_cache)
333
+
334
+ # Verify and decode the JWT
335
+ # jwt.decode() verifies the signature using the correct key from JWKS (based on 'kid' in header)
336
+ # However, it does NOT validate expiration/claims - that requires claims.validate()
337
+ claims = jwt.decode(
338
+ access_token,
339
+ jwk_set,
340
+ claims_options={
341
+ "exp": {"essential": True},
342
+ "iat": {"essential": True}
343
+ }
344
+ )
345
+
346
+ # CRITICAL: Validate claims (expiration, issued at, etc.)
347
+ # Without this, expired tokens would be accepted!
348
+ claims.validate()
349
+
350
+ logger.debug(f"Token verified successfully. User: {claims.get('sub')}")
351
+
352
+ # Extract user information from verified token
353
+ # Reference: https://workos.com/docs/reference/authkit/session-tokens/access-token
354
+ return {
355
+ 'user_id': claims.get('sub'), # User ID (subject)
356
+ 'session_id': claims.get('sid'), # Session ID
357
+ 'organization_id': claims.get('org_id'), # Organization ID
358
+ 'role': claims.get('role'), # User role (e.g., "member", "admin")
359
+ 'roles': claims.get('roles', []), # Array of roles
360
+ 'permissions': claims.get('permissions', []), # Permissions array
361
+ 'entitlements': claims.get('entitlements', []), # Entitlements array
362
+ 'exp': claims.get('exp'),
363
+ 'iat': claims.get('iat'),
364
+ }
365
+
366
+ except ExpiredTokenError:
367
+ logger.warning("Token has expired")
368
+ raise ValueError("Token has expired")
369
+ except BadSignatureError:
370
+ logger.warning("Invalid token signature")
371
+ raise ValueError("Invalid token signature - token may have been tampered with")
372
+ except DecodeError as e:
373
+ logger.warning(f"Failed to decode token: {e}")
374
+ raise ValueError(f"Invalid token format: {e}")
375
+ except InvalidClaimError as e:
376
+ logger.warning(f"Invalid token claim: {e}")
377
+ raise ValueError(f"Invalid token claim: {e}")
378
+ except Exception as e:
379
+ logger.error(f"Error verifying session: {type(e).__name__}: {e}", exc_info=True)
380
+ raise ValueError(f"Token verification failed: {str(e)}")
381
+
382
+
383
+ # refresh token
384
+ async def refresh_token(self, refresh_token_request: WorkOSRefreshTokenRequest) -> RefreshTokenResponse:
385
+ """
386
+ Refresh a WorkOS JWT access token.
387
+
388
+ Args:
389
+ refresh_token_request: WorkOSRefreshTokenRequest
390
+
391
+ Returns:
392
+ RefreshTokenResponse: Access token and refresh token
393
+ """
394
+ # Offload synchronous WorkOS call to thread pool to avoid blocking event loop
395
+ response = await asyncio.to_thread(
396
+ self.workos_client.user_management.authenticate_with_refresh_token,
397
+ refresh_token=refresh_token_request.refresh_token,
398
+ ip_address=refresh_token_request.ip_address,
399
+ user_agent=refresh_token_request.user_agent
400
+ )
401
+ return RefreshTokenResponse(
402
+ access_token=response.access_token,
403
+ refresh_token=response.refresh_token
404
+ )
405
+
@@ -0,0 +1,165 @@
1
+ """
2
+ Task service layer
3
+ Business logic for task operations
4
+ Reference: https://fastapi.tiangolo.com/tutorial/sql-databases/
5
+ """
6
+ from typing import List, Optional
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy import select, update, delete
9
+ from sqlalchemy.orm import selectinload
10
+
11
+ from app.models.task import Task
12
+ from app.api.v1.schemas.task import TaskCreate, TaskUpdate
13
+
14
+
15
+ class TaskService:
16
+ """
17
+ Service class for task-related business logic
18
+ Handles all database operations for tasks
19
+ """
20
+
21
+ @staticmethod
22
+ async def get_task(db: AsyncSession, task_id: int) -> Optional[Task]:
23
+ """
24
+ Retrieve a single task by ID
25
+
26
+ Args:
27
+ db: Database session
28
+ task_id: ID of the task to retrieve
29
+
30
+ Returns:
31
+ Task object if found, None otherwise
32
+ """
33
+ # Use select() for async queries (SQLAlchemy 2.0 style)
34
+ # Reference: https://docs.sqlalchemy.org/en/20/tutorial/data_select.html
35
+ result = await db.execute(select(Task).where(Task.id == task_id))
36
+ return result.scalar_one_or_none()
37
+
38
+ @staticmethod
39
+ async def get_tasks(
40
+ db: AsyncSession,
41
+ skip: int = 0,
42
+ limit: int = 100,
43
+ completed: Optional[bool] = None
44
+ ) -> List[Task]:
45
+ """
46
+ Retrieve multiple tasks with optional filtering
47
+
48
+ Args:
49
+ db: Database session
50
+ skip: Number of records to skip (for pagination)
51
+ limit: Maximum number of records to return
52
+ completed: Optional filter by completion status
53
+
54
+ Returns:
55
+ List of Task objects
56
+ """
57
+ query = select(Task)
58
+
59
+ # Apply filter if provided
60
+ if completed is not None:
61
+ query = query.where(Task.completed == completed)
62
+
63
+ # Apply pagination
64
+ query = query.offset(skip).limit(limit)
65
+
66
+ # Order by creation date (newest first)
67
+ query = query.order_by(Task.created_at.desc())
68
+
69
+ result = await db.execute(query)
70
+ return list(result.scalars().all())
71
+
72
+ @staticmethod
73
+ async def create_task(db: AsyncSession, task_data: TaskCreate) -> Task:
74
+ """
75
+ Create a new task
76
+
77
+ Args:
78
+ db: Database session
79
+ task_data: Task creation data
80
+
81
+ Returns:
82
+ Created Task object
83
+
84
+ Note: Don't commit here - let the get_db() dependency handle commit/rollback
85
+ Reference: https://docs.sqlalchemy.org/en/20/orm/session_basics.html#committing
86
+ """
87
+ # Create new task instance from schema data
88
+ task = Task(
89
+ title=task_data.title,
90
+ description=task_data.description,
91
+ completed=task_data.completed
92
+ )
93
+
94
+ # Add to session
95
+ db.add(task)
96
+ # Flush to get database-generated ID (without committing)
97
+ # This sends the INSERT to the database and gets the ID back
98
+ # With server_default, timestamps are set by the database
99
+ await db.flush()
100
+ # After flush, task.id is available
101
+ # For timestamps, we'll let the response handle it
102
+ # The database has the values, but we don't refresh to avoid connection issues
103
+
104
+ # Note: Commit will be handled by get_db() dependency
105
+ # Timestamps will be None initially but the database has the correct values
106
+ return task
107
+
108
+ @staticmethod
109
+ async def update_task(
110
+ db: AsyncSession,
111
+ task_id: int,
112
+ task_data: TaskUpdate
113
+ ) -> Optional[Task]:
114
+ """
115
+ Update an existing task
116
+
117
+ Args:
118
+ db: Database session
119
+ task_id: ID of the task to update
120
+ task_data: Task update data (partial)
121
+
122
+ Returns:
123
+ Updated Task object if found, None otherwise
124
+ """
125
+ # Get existing task
126
+ task = await TaskService.get_task(db, task_id)
127
+ if not task:
128
+ return None
129
+
130
+ # Update only provided fields
131
+ update_data = task_data.model_dump(exclude_unset=True) # Only include set fields
132
+ for field, value in update_data.items():
133
+ setattr(task, field, value)
134
+
135
+ # Don't commit here - let the get_db() dependency handle commit/rollback
136
+ await db.flush() # Flush changes to database (without committing)
137
+ # Don't refresh here - timestamps will be available after commit
138
+ # In serverless, refreshing before commit can cause connection issues
139
+
140
+ return task
141
+
142
+ @staticmethod
143
+ async def delete_task(db: AsyncSession, task_id: int) -> bool:
144
+ """
145
+ Delete a task
146
+
147
+ Args:
148
+ db: Database session
149
+ task_id: ID of the task to delete
150
+
151
+ Returns:
152
+ True if task was deleted, False if not found
153
+ """
154
+ # Get task first
155
+ task = await TaskService.get_task(db, task_id)
156
+ if not task:
157
+ return False
158
+
159
+ # Delete task
160
+ # Don't commit here - let the get_db() dependency handle commit/rollback
161
+ await db.delete(task)
162
+ # No need to flush for delete - commit will handle it
163
+
164
+ return True
165
+
@@ -0,0 +1,108 @@
1
+ import asyncio
2
+ from typing import List
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from workos import WorkOSClient
6
+ from datetime import datetime, timezone
7
+ from app.core.config import settings
8
+ from app.models.user import User
9
+ from app.api.v1.schemas.user import UserCreate, UserUpdate
10
+
11
+ class UserService:
12
+ def __init__(self):
13
+ self.workos_client = WorkOSClient(
14
+ api_key=settings.WORKOS_API_KEY,
15
+ client_id=settings.WORKOS_CLIENT_ID
16
+ )
17
+
18
+ async def get_user(self, db: AsyncSession, user_id: str) -> User:
19
+ result = await db.execute(select(User).where(User.id == user_id))
20
+ return result.scalar_one_or_none()
21
+
22
+ async def get_users(self, db: AsyncSession, skip: int = 0, limit: int = 100) -> List[User]:
23
+ result = await db.execute(select(User).offset(skip).limit(limit))
24
+ return list(result.scalars().all())
25
+
26
+ async def create_user(self, db: AsyncSession, user_data: UserCreate) -> User:
27
+
28
+ create_user_payload = {
29
+ "email": user_data.email,
30
+ "password": user_data.password,
31
+ "first_name": user_data.first_name,
32
+ "last_name": user_data.last_name,
33
+ }
34
+
35
+ # Create user in WorkOS
36
+ # Offload synchronous WorkOS call to thread pool to avoid blocking event loop
37
+ workos_user_response = await asyncio.to_thread(
38
+ self.workos_client.user_management.create_user,
39
+ **create_user_payload
40
+ )
41
+
42
+ # Send verification email
43
+ # On signup, we don't send the verification email to the user, because it will be sent later in the login process for the first time.
44
+ # self.workos_client.user_management.send_verification_email(
45
+ # user_id=workos_user_response.id
46
+ # )
47
+
48
+ # Create user in database
49
+ user = User(
50
+ id=workos_user_response.id,
51
+ email=workos_user_response.email,
52
+ first_name=workos_user_response.first_name,
53
+ last_name=workos_user_response.last_name
54
+ )
55
+ db.add(user)
56
+ await db.flush()
57
+ return user
58
+
59
+ async def update_user(self, db: AsyncSession, user_id: str, user_data: UserUpdate):
60
+ existing_user = await self.get_user(db, user_id)
61
+ if not existing_user:
62
+ return None
63
+
64
+ # Get only fields that were explicitly set (exclude_unset=True)
65
+ # This prevents sending None values for omitted fields, which could clear them in WorkOS
66
+ # Reference: https://docs.pydantic.dev/latest/api/standard_library/#pydantic.BaseModel.model_dump
67
+ update_data = user_data.model_dump(exclude_unset=True)
68
+
69
+ # Early return if no fields to update
70
+ if not update_data:
71
+ return existing_user
72
+
73
+ # Offload synchronous WorkOS call to thread pool to avoid blocking event loop
74
+ # Only send fields that were explicitly provided (prevents clearing fields with None)
75
+ await asyncio.to_thread(
76
+ self.workos_client.user_management.update_user,
77
+ user_id=user_id,
78
+ **update_data
79
+ )
80
+
81
+ # Update the database model with the same filtered data
82
+ for field, value in update_data.items():
83
+ setattr(existing_user, field, value)
84
+
85
+ # Don't commit here - let the get_db() dependency handle commit/rollback
86
+ await db.flush() # flush changes to database (without committing)
87
+ # Don't refresh here - timestamps will be available after commit
88
+ # In serverless, refreshing before commit can cause connection issues
89
+
90
+ # Manually set updated_at since we can't reliably refresh in serverless environments
91
+ existing_user.updated_at = datetime.now(timezone.utc)
92
+
93
+ return existing_user
94
+
95
+
96
+ async def delete_user(self, db: AsyncSession, user_id: str) -> bool:
97
+ existing_user = await self.get_user(db, user_id)
98
+ if not existing_user:
99
+ return False
100
+
101
+ # Offload synchronous WorkOS call to thread pool to avoid blocking event loop
102
+ await asyncio.to_thread(
103
+ self.workos_client.user_management.delete_user,
104
+ user_id=user_id
105
+ )
106
+
107
+ await db.delete(existing_user)
108
+ return True