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.
- fastapi_auth_starter/__init__.py +7 -0
- fastapi_auth_starter/cli.py +326 -0
- fastapi_auth_starter-0.1.3.data/data/README.md +247 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/README +1 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/env.py +100 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/script.py.mako +28 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/versions/279c472f4fd8_add_user_table.py +42 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/versions/5f062b3648fa_change_user_id_from_uuid_to_string_for_.py +38 -0
- fastapi_auth_starter-0.1.3.data/data/alembic/versions/8d275132562b_create_tasks_table.py +44 -0
- fastapi_auth_starter-0.1.3.data/data/alembic.ini +150 -0
- fastapi_auth_starter-0.1.3.data/data/app/__init__.py +5 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/api.py +21 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/auth.py +513 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/health.py +50 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/task.py +182 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/routes/user.py +144 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/__init__.py +8 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/auth.py +198 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/task.py +61 -0
- fastapi_auth_starter-0.1.3.data/data/app/api/v1/schemas/user.py +96 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/config.py +107 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/database.py +106 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/dependencies.py +148 -0
- fastapi_auth_starter-0.1.3.data/data/app/core/exceptions.py +7 -0
- fastapi_auth_starter-0.1.3.data/data/app/db/__init__.py +4 -0
- fastapi_auth_starter-0.1.3.data/data/app/main.py +91 -0
- fastapi_auth_starter-0.1.3.data/data/app/models/__init__.py +14 -0
- fastapi_auth_starter-0.1.3.data/data/app/models/task.py +56 -0
- fastapi_auth_starter-0.1.3.data/data/app/models/user.py +45 -0
- fastapi_auth_starter-0.1.3.data/data/app/services/__init__.py +8 -0
- fastapi_auth_starter-0.1.3.data/data/app/services/auth.py +405 -0
- fastapi_auth_starter-0.1.3.data/data/app/services/task.py +165 -0
- fastapi_auth_starter-0.1.3.data/data/app/services/user.py +108 -0
- fastapi_auth_starter-0.1.3.data/data/pyproject.toml +77 -0
- fastapi_auth_starter-0.1.3.data/data/runtime.txt +2 -0
- fastapi_auth_starter-0.1.3.data/data/vercel.json +19 -0
- fastapi_auth_starter-0.1.3.dist-info/METADATA +283 -0
- fastapi_auth_starter-0.1.3.dist-info/RECORD +44 -0
- fastapi_auth_starter-0.1.3.dist-info/WHEEL +4 -0
- 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
|