44from typing import Optional
55
66import httpx
7- from fastapi import APIRouter , Depends , HTTPException , status , Form , Request , Body , Query
7+ from fastapi import APIRouter , Depends , HTTPException , status , Form , Request , Body , Query , Response
88from sqlalchemy import select
99from sqlalchemy .ext .asyncio import AsyncSession
1010from sqlalchemy .orm import selectinload
1818 UserLogin ,
1919 UserResponse ,
2020 ClientCredentials ,
21- TokenResponse ,
2221 APIResponse ,
2322 ErrorDetail ,
2423)
@@ -148,8 +147,32 @@ async def signup(
148147 )
149148
150149
150+ def _set_auth_cookie (response : Response , token : str ) -> None :
151+ """Set httpOnly cookie with JWT token"""
152+ response .set_cookie (
153+ key = "access_token" ,
154+ value = token ,
155+ httponly = True , # Prevents JavaScript access (XSS protection)
156+ secure = settings .COOKIE_SECURE , # HTTPS only in production
157+ samesite = settings .COOKIE_SAMESITE , # CSRF protection
158+ max_age = settings .ACCESS_TOKEN_EXPIRE_MINUTES * 60 , # Convert to seconds
159+ path = "/" , # Available for all paths
160+ domain = settings .COOKIE_DOMAIN or None , # None = current domain
161+ )
162+
163+
164+ def _clear_auth_cookie (response : Response ) -> None :
165+ """Clear the auth cookie on logout"""
166+ response .delete_cookie (
167+ key = "access_token" ,
168+ path = "/" ,
169+ domain = settings .COOKIE_DOMAIN or None ,
170+ )
171+
172+
151173@router .post ("/login" , response_model = APIResponse )
152174async def login (
175+ response : Response ,
153176 credentials : UserLogin = Body (
154177 ...,
155178 openapi_examples = {
@@ -165,7 +188,7 @@ async def login(
165188 ),
166189 db : AsyncSession = Depends (get_db )
167190) -> APIResponse :
168- """Login and get access token"""
191+ """Login and get access token (stored in httpOnly cookie) """
169192 result = await db .execute (select (User ).where (User .email == credentials .email ))
170193 user = result .scalar_one_or_none ()
171194
@@ -175,11 +198,12 @@ async def login(
175198 if not user .is_active :
176199 return APIResponse (success = False , error = ErrorDetail (code = "USER_INACTIVE" , message = "User account is inactive" ))
177200
178- # Create access token
201+ # Create access token and set as httpOnly cookie
179202 access_token = create_access_token (data = {"sub" : user .id })
180- token_response = TokenResponse ( access_token = access_token )
203+ _set_auth_cookie ( response , access_token )
181204
182- return APIResponse (success = True , data = token_response .model_dump ())
205+ # Return success without exposing token in response body
206+ return APIResponse (success = True , data = {"message" : "Login successful" })
183207
184208
185209@router .post ("/token" , tags = ["Authentication" ])
@@ -312,6 +336,13 @@ async def get_current_user_info(
312336 return APIResponse (success = True , data = user_data )
313337
314338
339+ @router .post ("/logout" , response_model = APIResponse )
340+ async def logout (response : Response ) -> APIResponse :
341+ """Logout and clear auth cookie"""
342+ _clear_auth_cookie (response )
343+ return APIResponse (success = True , data = {"message" : "Logout successful" })
344+
345+
315346@router .get ("/credentials" , response_model = APIResponse )
316347async def get_credentials (current_user : User = Depends (get_current_user )) -> APIResponse :
317348 """Get API credentials (client_id only, client_secret is never returned)"""
0 commit comments