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,513 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from sqlalchemy.exc import IntegrityError
|
|
5
|
+
from workos.exceptions import BadRequestException, EmailVerificationRequiredException, NotFoundException
|
|
6
|
+
|
|
7
|
+
from app.api.v1.schemas.auth import AuthorizationRequest, AuthorizationUrlResponse, EmailVerificationRequiredResponse, ForgotPasswordRequest, ForgotPasswordResponse, LoginRequest, LoginResponse, OAuthCallbackRequest, RefreshTokenRequest, RefreshTokenResponse, ResetPasswordRequest, SignupRequest, SignupResponse, VerifyEmailRequest, VerifyEmailResponse, WorkOSAuthorizationRequest, WorkOSLoginRequest, WorkOSRefreshTokenRequest, WorkOSResetPasswordRequest, WorkOsVerifyEmailRequest
|
|
8
|
+
from app.api.v1.schemas.user import WorkOSUserResponse
|
|
9
|
+
from app.core.config import settings
|
|
10
|
+
from app.core.database import get_db
|
|
11
|
+
from app.core.dependencies import get_auth_service
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
router = APIRouter(
|
|
17
|
+
prefix="/auth",
|
|
18
|
+
tags=["auth"],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post(
|
|
23
|
+
"/signup",
|
|
24
|
+
response_model=SignupResponse,
|
|
25
|
+
summary="Sign up a new user",
|
|
26
|
+
description="Create a new user account. Email verification required before login.",
|
|
27
|
+
status_code=status.HTTP_201_CREATED
|
|
28
|
+
)
|
|
29
|
+
async def signup(
|
|
30
|
+
signup_request: SignupRequest,
|
|
31
|
+
db: AsyncSession = Depends(get_db)
|
|
32
|
+
) -> SignupResponse:
|
|
33
|
+
"""
|
|
34
|
+
Sign up a new user.
|
|
35
|
+
|
|
36
|
+
Creates a new user account in WorkOS and your database.
|
|
37
|
+
User must verify their email before they can login.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
signup_request: User signup data (email, password, name, etc.)
|
|
41
|
+
db: Database session
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
SignupResponse: User information (no tokens - email verification required)
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
HTTPException: 409 if email already exists, 400 for validation errors
|
|
48
|
+
"""
|
|
49
|
+
auth_service = get_auth_service()
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return await auth_service.signup(
|
|
53
|
+
db=db,
|
|
54
|
+
email=signup_request.email,
|
|
55
|
+
password=signup_request.password,
|
|
56
|
+
first_name=signup_request.first_name,
|
|
57
|
+
last_name=signup_request.last_name
|
|
58
|
+
)
|
|
59
|
+
except BadRequestException as e:
|
|
60
|
+
# Handle WorkOS validation errors
|
|
61
|
+
if hasattr(e, 'errors') and e.errors:
|
|
62
|
+
for error in e.errors:
|
|
63
|
+
error_code = error.get('code', '')
|
|
64
|
+
|
|
65
|
+
if error_code == 'email_not_available':
|
|
66
|
+
raise HTTPException(
|
|
67
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
68
|
+
detail="Email address is already registered. Please use a different email or try logging in."
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
if error_code == 'invalid_email':
|
|
72
|
+
raise HTTPException(
|
|
73
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
74
|
+
detail="Invalid email address format"
|
|
75
|
+
) from e
|
|
76
|
+
|
|
77
|
+
# Generic WorkOS error
|
|
78
|
+
raise HTTPException(
|
|
79
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
80
|
+
detail=f"Failed to create account: {e.message if hasattr(e, 'message') else str(e)}"
|
|
81
|
+
)
|
|
82
|
+
except IntegrityError as e:
|
|
83
|
+
# Handle database integrity errors (e.g., duplicate email)
|
|
84
|
+
error_str = str(e.orig) if hasattr(e, 'orig') else str(e)
|
|
85
|
+
|
|
86
|
+
# Check if it's a duplicate email constraint violation
|
|
87
|
+
if "ix_users_email" in error_str or "duplicate key" in error_str.lower() or "unique constraint" in error_str.lower():
|
|
88
|
+
logger.warning(f"Duplicate email during signup: {signup_request.email}")
|
|
89
|
+
raise HTTPException(
|
|
90
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
91
|
+
detail="An account with this email address already exists. Please try logging in or resetting your password."
|
|
92
|
+
) from e
|
|
93
|
+
|
|
94
|
+
# Other integrity errors (shouldn't happen, but handle gracefully)
|
|
95
|
+
logger.error(f"Database integrity error during signup: {e}", exc_info=True)
|
|
96
|
+
raise HTTPException(
|
|
97
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
98
|
+
detail="Failed to create account due to a data conflict"
|
|
99
|
+
) from e
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error(f"Unexpected error during signup: {type(e).__name__}: {e}", exc_info=True)
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
104
|
+
detail="An unexpected error occurred while creating your account"
|
|
105
|
+
) from e
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@router.post(
|
|
110
|
+
"/signin",
|
|
111
|
+
response_model=Union[LoginResponse, EmailVerificationRequiredResponse],
|
|
112
|
+
summary="Sign in a user with email and password",
|
|
113
|
+
status_code=status.HTTP_200_OK
|
|
114
|
+
)
|
|
115
|
+
async def login(login_request: LoginRequest, request: Request) -> Union[LoginResponse, EmailVerificationRequiredResponse]:
|
|
116
|
+
"""
|
|
117
|
+
Sign in a user with email and password.
|
|
118
|
+
|
|
119
|
+
If the user is not verified, returns an `EmailVerificationRequiredResponse`
|
|
120
|
+
containing a `pending_authentication_token` and `email_verification_id`.
|
|
121
|
+
These are used with the code sent to the user’s email to complete verification
|
|
122
|
+
through the `verify-email` endpoint.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
login_request (LoginRequest): User credentials.
|
|
126
|
+
request (Request): Current HTTP request context.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Union[LoginResponse, EmailVerificationRequiredResponse]
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
auth_service = get_auth_service()
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
workos_login_request = WorkOSLoginRequest(
|
|
138
|
+
email=login_request.email,
|
|
139
|
+
password=login_request.password,
|
|
140
|
+
ip_address=request.client.host if request.client else "",
|
|
141
|
+
user_agent=request.headers.get("user-agent") or ""
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return await auth_service.login(login_request=workos_login_request)
|
|
145
|
+
|
|
146
|
+
except EmailVerificationRequiredException as e:
|
|
147
|
+
response_data = e.response_json
|
|
148
|
+
|
|
149
|
+
return EmailVerificationRequiredResponse(
|
|
150
|
+
message="Email verification required",
|
|
151
|
+
pending_authentication_token=response_data.get('pending_authentication_token'),
|
|
152
|
+
email_verification_id=response_data.get('email_verification_id'),
|
|
153
|
+
email=response_data.get('email', login_request.email),
|
|
154
|
+
requires_verification=True
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
except BadRequestException as e:
|
|
158
|
+
# Handle WorkOS validation errors
|
|
159
|
+
error_code = getattr(e, 'code', None)
|
|
160
|
+
|
|
161
|
+
# Invalid credentials
|
|
162
|
+
if error_code == 'invalid_credentials':
|
|
163
|
+
logger.warning(f"Invalid login attempt for email: {login_request.email}")
|
|
164
|
+
raise HTTPException(
|
|
165
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
166
|
+
detail="Invalid email or password. Please check your credentials and try again."
|
|
167
|
+
) from e
|
|
168
|
+
|
|
169
|
+
# Check errors array if present
|
|
170
|
+
if hasattr(e, 'errors') and e.errors:
|
|
171
|
+
for error in e.errors:
|
|
172
|
+
error_code = error.get('code', '')
|
|
173
|
+
|
|
174
|
+
if error_code == 'invalid_email':
|
|
175
|
+
raise HTTPException(
|
|
176
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
177
|
+
detail="Invalid email address format"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Generic BadRequest
|
|
181
|
+
logger.warning(f"BadRequest during login: {e}")
|
|
182
|
+
raise HTTPException(
|
|
183
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
184
|
+
detail=f"Invalid request: {e.message if hasattr(e, 'message') else str(e)}"
|
|
185
|
+
) from e
|
|
186
|
+
|
|
187
|
+
except NotFoundException:
|
|
188
|
+
# User not found
|
|
189
|
+
raise HTTPException(
|
|
190
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
191
|
+
detail="No account found with this email address. Please sign up first."
|
|
192
|
+
) from None
|
|
193
|
+
|
|
194
|
+
except Exception as e:
|
|
195
|
+
# Log but don't expose internal errors
|
|
196
|
+
logger.error(f"Unexpected error during login: {e}", exc_info=True)
|
|
197
|
+
raise HTTPException(
|
|
198
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
199
|
+
detail="An unexpected error occurred. Please try again later."
|
|
200
|
+
) from e
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@router.post(
|
|
204
|
+
"/verify-email",
|
|
205
|
+
response_model=VerifyEmailResponse,
|
|
206
|
+
summary="Verify an email address",
|
|
207
|
+
status_code=status.HTTP_200_OK
|
|
208
|
+
)
|
|
209
|
+
async def verify_email(verify_email_request: VerifyEmailRequest, request: Request):
|
|
210
|
+
"""This endpoint is used to verify an email address.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
verify_email_request: WorkOsVerifyEmailRequest
|
|
214
|
+
request: Request
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
VerifyEmailResponse
|
|
218
|
+
"""
|
|
219
|
+
auth_service = get_auth_service()
|
|
220
|
+
try:
|
|
221
|
+
workos_verify_email_request = WorkOsVerifyEmailRequest(
|
|
222
|
+
pending_authentication_token=verify_email_request.pending_authentication_token,
|
|
223
|
+
code=verify_email_request.code,
|
|
224
|
+
ip_address=request.client.host if request.client else "",
|
|
225
|
+
user_agent=request.headers.get("user-agent") or ""
|
|
226
|
+
)
|
|
227
|
+
return await auth_service.verify_email(verify_email_request=workos_verify_email_request)
|
|
228
|
+
except BadRequestException as e:
|
|
229
|
+
error_code = getattr(e, "code", None)
|
|
230
|
+
error_description = getattr(e, "error_description", "") or ""
|
|
231
|
+
if error_code in {"invalid_code", "invalid_token"} or "invalid" in error_description:
|
|
232
|
+
raise HTTPException(
|
|
233
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
234
|
+
detail="Invalid or expired verification code."
|
|
235
|
+
) from e
|
|
236
|
+
logger.error(f"Failed to verify email: {e}", exc_info=True)
|
|
237
|
+
raise HTTPException(
|
|
238
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
239
|
+
detail="Unable to verify email with the provided details."
|
|
240
|
+
) from e
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.error(f"Unexpected error during email verification: {e}", exc_info=True)
|
|
243
|
+
raise HTTPException(
|
|
244
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
245
|
+
detail="An unexpected error occurred while verifying email."
|
|
246
|
+
) from e
|
|
247
|
+
|
|
248
|
+
@router.post(
|
|
249
|
+
"/forgot-password",
|
|
250
|
+
response_model=ForgotPasswordResponse,
|
|
251
|
+
summary="Forgot password",
|
|
252
|
+
status_code=status.HTTP_200_OK
|
|
253
|
+
)
|
|
254
|
+
async def forgot_password(forgot_password_request: ForgotPasswordRequest) -> ForgotPasswordResponse:
|
|
255
|
+
"""
|
|
256
|
+
Request a password reset for a user account.
|
|
257
|
+
|
|
258
|
+
Sends a password reset email to the provided email address if an account exists.
|
|
259
|
+
The email contains a link to reset the password. The reset URL is configured
|
|
260
|
+
in the WorkOS Dashboard under Developer → Redirects → Password reset URL.
|
|
261
|
+
|
|
262
|
+
For security, the response message does not reveal whether the email address
|
|
263
|
+
is registered in the system.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
forgot_password_request (ForgotPasswordRequest): Request containing the user's email address.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
ForgotPasswordResponse: Success message indicating a reset link was sent (if account exists).
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
HTTPException: 400 if email format is invalid, 500 for other errors.
|
|
273
|
+
"""
|
|
274
|
+
auth_service = get_auth_service()
|
|
275
|
+
try:
|
|
276
|
+
return await auth_service.forgot_password(forgot_password_request=forgot_password_request)
|
|
277
|
+
except BadRequestException as e:
|
|
278
|
+
# Handle validation errors (invalid email format)
|
|
279
|
+
error_code = getattr(e, 'code', None)
|
|
280
|
+
errors = getattr(e, 'errors', [])
|
|
281
|
+
|
|
282
|
+
# Check for email validation errors
|
|
283
|
+
email_error_codes = ['email_required', 'invalid_email']
|
|
284
|
+
has_email_error = (
|
|
285
|
+
error_code in email_error_codes or
|
|
286
|
+
any(err.get('code') in email_error_codes for err in errors if isinstance(err, dict))
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if has_email_error:
|
|
290
|
+
logger.warning(f"Invalid email format in forgot password request: {forgot_password_request.email}")
|
|
291
|
+
raise HTTPException(
|
|
292
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
293
|
+
detail="Invalid email address format"
|
|
294
|
+
) from e
|
|
295
|
+
|
|
296
|
+
# Other BadRequestException errors
|
|
297
|
+
logger.error(f"Error during forgot password: {e}", exc_info=True)
|
|
298
|
+
raise HTTPException(
|
|
299
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
300
|
+
detail="Invalid request"
|
|
301
|
+
) from e
|
|
302
|
+
except NotFoundException:
|
|
303
|
+
# User not found - return generic success message to prevent email enumeration
|
|
304
|
+
# This is a security best practice: don't reveal if an email exists
|
|
305
|
+
logger.debug(f"Password reset requested for non-existent email: {forgot_password_request.email}")
|
|
306
|
+
return ForgotPasswordResponse(
|
|
307
|
+
message="If an account exists with this email address, a password reset link has been sent."
|
|
308
|
+
)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
# Unexpected errors
|
|
311
|
+
logger.error(f"Unexpected error during forgot password: {type(e).__name__}: {e}", exc_info=True)
|
|
312
|
+
raise HTTPException(
|
|
313
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
314
|
+
detail="Failed to send password reset email"
|
|
315
|
+
) from e
|
|
316
|
+
|
|
317
|
+
@router.post(
|
|
318
|
+
"/reset-password",
|
|
319
|
+
response_model=WorkOSUserResponse,
|
|
320
|
+
summary="Reset password",
|
|
321
|
+
status_code=status.HTTP_200_OK
|
|
322
|
+
)
|
|
323
|
+
async def reset_password(reset_password_request: ResetPasswordRequest) -> WorkOSUserResponse:
|
|
324
|
+
"""
|
|
325
|
+
Reset a user's password.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
reset_password_request (ResetPasswordRequest): Reset password request.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
WorkOSUserResponse: User information
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
auth_service = get_auth_service()
|
|
335
|
+
try:
|
|
336
|
+
workos_reset_password_request = WorkOSResetPasswordRequest(
|
|
337
|
+
token=reset_password_request.token,
|
|
338
|
+
new_password=reset_password_request.new_password
|
|
339
|
+
)
|
|
340
|
+
return await auth_service.reset_password(reset_password_request=workos_reset_password_request)
|
|
341
|
+
except BadRequestException as e:
|
|
342
|
+
error_code = getattr(e, 'code', None)
|
|
343
|
+
error_description = getattr(e, 'error_description', '')
|
|
344
|
+
if 'invalid_token' in error_description or error_code == 'invalid_token':
|
|
345
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired reset password token") from e
|
|
346
|
+
logger.error(f"Error during reset password: {e}", exc_info=True)
|
|
347
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reset password") from e
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.error(f"Error during reset password: {e}", exc_info=True)
|
|
350
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reset password") from e
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@router.post(
|
|
354
|
+
"/authorize",
|
|
355
|
+
summary="Generate OAuth2 authorization URL",
|
|
356
|
+
response_model=AuthorizationUrlResponse,
|
|
357
|
+
status_code=status.HTTP_200_OK
|
|
358
|
+
)
|
|
359
|
+
async def authorize(authorization_request: AuthorizationRequest) -> AuthorizationUrlResponse:
|
|
360
|
+
"""
|
|
361
|
+
Generate an OAuth2 authorization URL.
|
|
362
|
+
The supported provider values are `GoogleOAuth`, `MicrosoftOAuth`, `GitHubOAuth`, and `AppleOAuth`.
|
|
363
|
+
|
|
364
|
+
Frontend can choose:
|
|
365
|
+
- `provider="authkit"`: For unified interface with multiple auth methods
|
|
366
|
+
- `connection_id="conn_xxx"`: For direct provider connection (better UX for specific buttons)
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
authorization_request (AuthorizationRequest): Authorization request.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
AuthorizationUrlResponse: Authorization URL.
|
|
373
|
+
"""
|
|
374
|
+
# Validate redirect_uri against whitelist (security requirement)
|
|
375
|
+
if authorization_request.redirect_uri not in settings.allowed_redirect_uris_list:
|
|
376
|
+
raise HTTPException(
|
|
377
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
378
|
+
detail=f"Invalid redirect_uri. Must be one of: {settings.allowed_redirect_uris_list}"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# For SSO: Use default connection_id if not provided
|
|
382
|
+
if authorization_request.connection_id and not authorization_request.provider:
|
|
383
|
+
# SSO pattern - connection_id provided
|
|
384
|
+
workos_request = WorkOSAuthorizationRequest(
|
|
385
|
+
connection_id=authorization_request.connection_id,
|
|
386
|
+
redirect_uri=authorization_request.redirect_uri,
|
|
387
|
+
state=authorization_request.state
|
|
388
|
+
)
|
|
389
|
+
elif authorization_request.provider:
|
|
390
|
+
# AuthKit pattern
|
|
391
|
+
workos_request = WorkOSAuthorizationRequest(
|
|
392
|
+
provider=authorization_request.provider,
|
|
393
|
+
redirect_uri=authorization_request.redirect_uri,
|
|
394
|
+
state=authorization_request.state
|
|
395
|
+
)
|
|
396
|
+
else:
|
|
397
|
+
# Fallback: Try default connection_id if available
|
|
398
|
+
if settings.WORKOS_DEFAULT_CONNECTION_ID:
|
|
399
|
+
workos_request = WorkOSAuthorizationRequest(
|
|
400
|
+
connection_id=settings.WORKOS_DEFAULT_CONNECTION_ID,
|
|
401
|
+
redirect_uri=authorization_request.redirect_uri,
|
|
402
|
+
state=authorization_request.state
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
raise HTTPException(
|
|
406
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
407
|
+
detail="Either 'provider' or 'connection_id' must be provided"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
auth_service = get_auth_service()
|
|
411
|
+
try:
|
|
412
|
+
authorization_url = await auth_service.generate_oauth2_authorization_url(workos_request)
|
|
413
|
+
return {"authorization_url": authorization_url}
|
|
414
|
+
except Exception as e:
|
|
415
|
+
logger.error(f"Error generating authorization URL: {e}", exc_info=True)
|
|
416
|
+
raise HTTPException(
|
|
417
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
418
|
+
detail="Failed to generate authorization URL"
|
|
419
|
+
) from e
|
|
420
|
+
|
|
421
|
+
@router.post(
|
|
422
|
+
"/callback",
|
|
423
|
+
response_model=LoginResponse,
|
|
424
|
+
summary="Exchange OAuth2 code for access token and refresh token",
|
|
425
|
+
status_code=status.HTTP_200_OK
|
|
426
|
+
)
|
|
427
|
+
async def callback(callback_request: OAuthCallbackRequest) -> LoginResponse:
|
|
428
|
+
"""
|
|
429
|
+
Exchange an OAuth2 code for access and refresh token.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
callback_request (OAuthCallbackRequest): Callback request.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
LoginResponse: Access token and refresh token
|
|
436
|
+
"""
|
|
437
|
+
auth_service = get_auth_service()
|
|
438
|
+
try:
|
|
439
|
+
return await auth_service.oauth2_callback(code=callback_request.code)
|
|
440
|
+
except BadRequestException as e:
|
|
441
|
+
error_code = getattr(e, 'code', None)
|
|
442
|
+
error_description = getattr(e, 'error_description', '')
|
|
443
|
+
if 'invalid_grant' in error_description or error_code == 'invalid_grant':
|
|
444
|
+
raise HTTPException(
|
|
445
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
446
|
+
detail="Invalid or expired authorization code. Please request a new authorization code."
|
|
447
|
+
) from e
|
|
448
|
+
if error_code == 'invalid_credentials':
|
|
449
|
+
raise HTTPException(
|
|
450
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
451
|
+
detail="Invalid credentials"
|
|
452
|
+
) from e
|
|
453
|
+
if error_code == 'invalid_code':
|
|
454
|
+
raise HTTPException(
|
|
455
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
456
|
+
detail="Invalid code"
|
|
457
|
+
) from e
|
|
458
|
+
logger.error(f"Error exchanging OAuth2 code: {e}", exc_info=True)
|
|
459
|
+
raise HTTPException(
|
|
460
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
461
|
+
detail="Failed to exchange OAuth2 code"
|
|
462
|
+
) from e
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.error(f"Error exchanging OAuth2 code: {e}", exc_info=True)
|
|
465
|
+
raise HTTPException(
|
|
466
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
467
|
+
detail="Failed to exchange OAuth2 code"
|
|
468
|
+
) from e
|
|
469
|
+
|
|
470
|
+
@router.post(
|
|
471
|
+
"/refresh-token",
|
|
472
|
+
response_model=RefreshTokenResponse,
|
|
473
|
+
summary="Refresh a token",
|
|
474
|
+
status_code=status.HTTP_200_OK
|
|
475
|
+
)
|
|
476
|
+
async def refresh_token(refresh_token_request: RefreshTokenRequest, request: Request) -> RefreshTokenResponse:
|
|
477
|
+
"""
|
|
478
|
+
Refresh a token.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
refresh_token_request (RefreshTokenRequest): Refresh token request.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
RefreshTokenResponse: Access token and refresh token
|
|
485
|
+
"""
|
|
486
|
+
auth_service = get_auth_service()
|
|
487
|
+
try:
|
|
488
|
+
workos_refresh_token_request = WorkOSRefreshTokenRequest(
|
|
489
|
+
refresh_token=refresh_token_request.refresh_token,
|
|
490
|
+
ip_address=request.client.host if request.client else "",
|
|
491
|
+
user_agent=request.headers.get("user-agent") or ""
|
|
492
|
+
)
|
|
493
|
+
return await auth_service.refresh_token(refresh_token_request=workos_refresh_token_request)
|
|
494
|
+
except BadRequestException as e:
|
|
495
|
+
error_code = getattr(e, 'code', None)
|
|
496
|
+
error_description = getattr(e, 'error_description', '')
|
|
497
|
+
if 'invalid_grant' in error_description or error_code == 'invalid_grant':
|
|
498
|
+
raise HTTPException(
|
|
499
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
500
|
+
detail="Invalid or expired refresh token. Please request a new refresh token."
|
|
501
|
+
) from e
|
|
502
|
+
# Handle other BadRequestException cases
|
|
503
|
+
logger.error(f"Error refreshing token: {e}", exc_info=True)
|
|
504
|
+
raise HTTPException(
|
|
505
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
506
|
+
detail="Failed to refresh token"
|
|
507
|
+
) from e
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.error(f"Error refreshing token: {e}", exc_info=True)
|
|
510
|
+
raise HTTPException(
|
|
511
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
512
|
+
detail="Failed to refresh token"
|
|
513
|
+
) from e
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health check endpoint
|
|
3
|
+
Provides basic health status for the application
|
|
4
|
+
Reference: https://fastapi.tiangolo.com/tutorial/bigger-applications/
|
|
5
|
+
"""
|
|
6
|
+
from fastapi import APIRouter
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Create router for health-related endpoints
|
|
11
|
+
# Reference: https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter
|
|
12
|
+
router = APIRouter(
|
|
13
|
+
prefix="/health",
|
|
14
|
+
tags=["health"], # Groups endpoints in API documentation
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HealthResponse(BaseModel):
|
|
19
|
+
"""
|
|
20
|
+
Response model for health check endpoint
|
|
21
|
+
Reference: https://fastapi.tiangolo.com/tutorial/response-model/
|
|
22
|
+
"""
|
|
23
|
+
status: str
|
|
24
|
+
message: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get(
|
|
28
|
+
"",
|
|
29
|
+
response_model=HealthResponse,
|
|
30
|
+
summary="Health check",
|
|
31
|
+
description="Returns the health status of the application",
|
|
32
|
+
)
|
|
33
|
+
async def health_check() -> HealthResponse:
|
|
34
|
+
"""
|
|
35
|
+
Health check endpoint
|
|
36
|
+
|
|
37
|
+
Returns a simple status message indicating the service is running.
|
|
38
|
+
This is useful for:
|
|
39
|
+
- Load balancer health checks
|
|
40
|
+
- Monitoring systems
|
|
41
|
+
- Container orchestration (Kubernetes liveness/readiness probes)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
HealthResponse: Status and message indicating service health
|
|
45
|
+
"""
|
|
46
|
+
return HealthResponse(
|
|
47
|
+
status="healthy",
|
|
48
|
+
message="Service is running"
|
|
49
|
+
)
|
|
50
|
+
|