88from sqlalchemy import select
99from sqlalchemy .ext .asyncio import AsyncSession
1010
11+ from app .api .middleware .rate_limit import auth_rate_limit
12+ from app .core .config import settings
1113from app .core .database import get_db
12- from app .core .deps import get_current_user , oauth2_scheme
14+ from app .core .deps import oauth2_scheme
1315from app .core .security import (
1416 create_access_token ,
1517 create_password_reset_token ,
18+ create_refresh_token ,
1619 decode_token ,
1720 hash_password ,
1821)
2831 RegisterResponse ,
2932 ResetPasswordRequest ,
3033 TokenResponse ,
31- TokenResponseWithRefresh ,
3234)
33- from app .api .middleware .rate_limit import auth_rate_limit
3435from app .services import auth_service
3536
3637logger = logging .getLogger (__name__ )
@@ -78,7 +79,7 @@ async def login(
7879 Set use_cookies=false to receive the refresh token in the response body (less secure).
7980 """
8081 result = await auth_service .login (db , body .email , body .password )
81-
82+
8283 # Set refresh token as httpOnly cookie for security (SEC-3 fix)
8384 if use_cookies and result .refresh_token :
8485 response .set_cookie (
@@ -90,7 +91,7 @@ async def login(
9091 max_age = 7 * 24 * 60 * 60 , # 7 days
9192 path = "/api/v1/auth/refresh" , # Only sent to refresh endpoint
9293 )
93-
94+
9495 return TokenResponse (
9596 access_token = result .access_token ,
9697 token_type = result .token_type ,
@@ -116,16 +117,16 @@ async def refresh(
116117 """
117118 # Try to get refresh token from cookie first (more secure)
118119 refresh_token = request .cookies .get ("refresh_token" ) or token
119-
120+
120121 if not refresh_token :
121122 raise HTTPException (
122123 status_code = status .HTTP_401_UNAUTHORIZED ,
123124 detail = "Refresh token required" ,
124125 headers = {"WWW-Authenticate" : "Bearer" },
125126 )
126-
127+
127128 result = await auth_service .refresh_token (db , refresh_token )
128-
129+
129130 # Update refresh token cookie if using cookie-based auth
130131 if request .cookies .get ("refresh_token" ) and result .refresh_token :
131132 response .set_cookie (
@@ -137,7 +138,7 @@ async def refresh(
137138 max_age = 7 * 24 * 60 * 60 ,
138139 path = "/api/v1/auth/refresh" ,
139140 )
140-
141+
141142 return TokenResponse (
142143 access_token = result .access_token ,
143144 token_type = result .token_type ,
@@ -185,6 +186,7 @@ async def gmail_oauth_url() -> GmailUrlResponse:
185186async def gmail_callback (
186187 body : GmailCallbackRequest ,
187188 request : Request ,
189+ response : Response ,
188190 db : AsyncSession = Depends (get_db ),
189191) -> GmailCallbackResponse :
190192 """Exchange the Google OAuth authorization code.
@@ -218,6 +220,21 @@ async def gmail_callback(
218220
219221 # Unauthenticated flow – login / register via Gmail.
220222 result = await auth_service .handle_gmail_login (db , body .code )
223+
224+ # Mirror the login endpoint: issue a refresh token and deliver it as an
225+ # httpOnly cookie so the client can silently renew the access token.
226+ if result .get ("access_token" ):
227+ refresh_token = create_refresh_token ({"sub" : result ["email" ]})
228+ response .set_cookie (
229+ key = "refresh_token" ,
230+ value = refresh_token ,
231+ httponly = True ,
232+ secure = settings .is_production ,
233+ samesite = "strict" ,
234+ max_age = 7 * 24 * 60 * 60 , # 7 days
235+ path = "/api/v1/auth/refresh" ,
236+ )
237+
221238 return GmailCallbackResponse (
222239 status = result ["status" ],
223240 email = result ["email" ],
0 commit comments