From 518bed9df30df5169ff9d1fe40153849d08394bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 8 Mar 2026 11:05:40 +0300 Subject: [PATCH 1/6] feat: Implement HttpOnly cookies for access and refresh tokens in authentication routes --- app/api/deps.py | 19 ++++++++++++++++--- app/api/routes/auth.py | 28 +++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/app/api/deps.py b/app/api/deps.py index ec9261d..6fc421c 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -2,7 +2,7 @@ from collections.abc import AsyncGenerator from typing import Annotated -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession @@ -17,7 +17,10 @@ from app.schemas.token import TokenPayload from app.schemas.user import SystemRole -reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") +# auto_error=False: cookie tabanlı auth kullanıldığında Bearer token isteğe bağlıdır +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/auth/login", auto_error=False +) async def get_db() -> AsyncGenerator[AsyncSession, None]: @@ -27,12 +30,22 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: async def get_current_user( + request: Request, db: Annotated[AsyncSession, Depends(get_db)], - token: Annotated[str, Depends(reusable_oauth2)], + bearer_token: Annotated[str | None, Depends(reusable_oauth2)] = None, ) -> User: """ Get current authenticated user from JWT token. + Cookie takes priority; falls back to Authorization Bearer header. """ + token = request.cookies.get("access_token") or bearer_token + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ErrorMessages.INVALID_TOKEN, + headers={"WWW-Authenticate": "Bearer"}, + ) + try: # Check if token is blacklisted if await is_token_blacklisted(db, token): diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index e0bf618..d687ae3 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -52,6 +52,17 @@ async def login_access_token( password=form_data.password, ) + # Set access token in HttpOnly cookie + response.set_cookie( + key="access_token", + value=result.access_token, + httponly=True, + secure=settings.ENVIRONMENT != "local", + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + path="/", + ) + # Set refresh token in HttpOnly cookie response.set_cookie( key="refresh_token", @@ -86,6 +97,7 @@ async def login_access_token( @router.post("/refresh", response_model=Token, status_code=status.HTTP_200_OK) async def refresh_token( request: Request, + response: Response, session: SessionDep, ) -> Token: """ @@ -99,9 +111,22 @@ async def refresh_token( ) try: - return await refresh_token_service( + result = await refresh_token_service( request=request, session=session, refresh_token=refresh_token_cookie ) + + # Set new access token in HttpOnly cookie + response.set_cookie( + key="access_token", + value=result.access_token, + httponly=True, + secure=settings.ENVIRONMENT != "local", + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + path="/", + ) + + return result except HTTPException: raise except Exception as e: @@ -129,6 +154,7 @@ async def logout(request: Request, response: Response, session: SessionDep) -> M request=request, session=session, refresh_token=refresh_token ) + response.delete_cookie(key="access_token", path="/") response.delete_cookie( key="refresh_token", path=f"{settings.API_V1_STR}/auth/refresh", From 39fa384538e71785b3ac96ce6a4df0cb1849d32d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 8 Mar 2026 11:21:20 +0300 Subject: [PATCH 2/6] refactor: Remove auto_error parameter from OAuth2PasswordBearer initialization --- app/api/deps.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/api/deps.py b/app/api/deps.py index 6fc421c..ace527f 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -17,10 +17,7 @@ from app.schemas.token import TokenPayload from app.schemas.user import SystemRole -# auto_error=False: cookie tabanlı auth kullanıldığında Bearer token isteğe bağlıdır -reusable_oauth2 = OAuth2PasswordBearer( - tokenUrl=f"{settings.API_V1_STR}/auth/login", auto_error=False -) +reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") async def get_db() -> AsyncGenerator[AsyncSession, None]: From c59451bb07c12ca4803c1e3abedc344cbebace96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 8 Mar 2026 11:30:03 +0300 Subject: [PATCH 3/6] refactor: Add auto_error parameter to OAuth2PasswordBearer initialization --- app/api/deps.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/api/deps.py b/app/api/deps.py index ace527f..13fb4fc 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -17,7 +17,10 @@ from app.schemas.token import TokenPayload from app.schemas.user import SystemRole -reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/auth/login", + auto_error=False +) async def get_db() -> AsyncGenerator[AsyncSession, None]: From 6085d11a863d6ef120dd56ea6d9158ef61110b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 8 Mar 2026 11:41:58 +0300 Subject: [PATCH 4/6] feat: Update authentication responses to use CookieLoginResponse and CookieRefreshResponse --- app/api/deps.py | 3 +-- app/api/routes/auth.py | 22 ++++++++++++++-------- app/schemas/token.py | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/api/deps.py b/app/api/deps.py index 13fb4fc..57194a0 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -18,8 +18,7 @@ from app.schemas.user import SystemRole reusable_oauth2 = OAuth2PasswordBearer( - tokenUrl=f"{settings.API_V1_STR}/auth/login", - auto_error=False + tokenUrl=f"{settings.API_V1_STR}/auth/login", auto_error=False ) diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index d687ae3..b9876cc 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -9,7 +9,10 @@ from app.core.messages.error_message import ErrorMessages from app.core.messages.success_message import SuccessMessages from app.schemas.msg import Message -from app.schemas.token import LoginResponse, Token +from app.schemas.token import ( + CookieLoginResponse, + CookieRefreshResponse, +) from app.schemas.user import ( ForgotPassword, NewPassword, @@ -34,13 +37,15 @@ router = APIRouter() -@router.post("/login", response_model=LoginResponse, status_code=status.HTTP_200_OK) +@router.post( + "/login", response_model=CookieLoginResponse, status_code=status.HTTP_200_OK +) async def login_access_token( response: Response, request: Request, session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], -) -> LoginResponse: +) -> CookieLoginResponse: """ OAuth2 compatible token login, get an access token for future requests. """ @@ -74,8 +79,7 @@ async def login_access_token( path=f"{settings.API_V1_STR}/auth/refresh", ) - return LoginResponse( - access_token=result.access_token, + return CookieLoginResponse( user=result.user, message=result.message, ) @@ -94,12 +98,14 @@ async def login_access_token( raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR) -@router.post("/refresh", response_model=Token, status_code=status.HTTP_200_OK) +@router.post( + "/refresh", response_model=CookieRefreshResponse, status_code=status.HTTP_200_OK +) async def refresh_token( request: Request, response: Response, session: SessionDep, -) -> Token: +) -> CookieRefreshResponse: """ Refresh access token using the refresh token from cookie. """ @@ -126,7 +132,7 @@ async def refresh_token( path="/", ) - return result + return CookieRefreshResponse(message=result.message) except HTTPException: raise except Exception as e: diff --git a/app/schemas/token.py b/app/schemas/token.py index b669f0e..3b9b037 100644 --- a/app/schemas/token.py +++ b/app/schemas/token.py @@ -20,6 +20,21 @@ class AuthTokens(Token): message: str | None = None +class CookieLoginResponse(BaseModel): + """Login response without access_token in body (token is in HttpOnly cookie).""" + + token_type: str = "bearer" + user: UserPublic + message: str | None = None + + +class CookieRefreshResponse(BaseModel): + """Refresh response without access_token in body (token is in HttpOnly cookie).""" + + token_type: str = "bearer" + message: str | None = None + + # Contents of JWT token class TokenPayload(BaseModel): sub: str | None = None From 4f1a66421eaee7087faf47a7b5aa76dde4e3ad82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 8 Mar 2026 11:45:06 +0300 Subject: [PATCH 5/6] test: Update tests to assert access_token is stored as HttpOnly cookie --- app/tests/test_auth.py | 13 +++++-------- app/tests/test_users.py | 9 +++------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/app/tests/test_auth.py b/app/tests/test_auth.py index e478479..e603427 100644 --- a/app/tests/test_auth.py +++ b/app/tests/test_auth.py @@ -86,7 +86,7 @@ async def test_refresh_token_and_logout(client: AsyncClient): refresh_response = await client.post("/auth/refresh") assert refresh_response.status_code == 200 data = refresh_response.json() - assert "access_token" in data + assert refresh_response.cookies.get("access_token") is not None assert data["message"] == SuccessMessages.LOGIN_SUCCESS # Test Logout Endpoint @@ -149,7 +149,7 @@ async def test_verify_email_flow(client: AsyncClient): ) assert login_response.status_code == 200 data = login_response.json() - assert "access_token" in data + assert login_response.cookies.get("access_token") is not None assert data["message"] == SuccessMessages.LOGIN_SUCCESS @@ -282,19 +282,17 @@ async def test_change_password(client: AsyncClient): ) await session.commit() - # 3. Login to get access token + # 3. Login to get access token cookie login_response = await client.post( "/auth/login", data={"username": email, "password": old_password} ) assert login_response.status_code == 200 - access_token = login_response.json()["access_token"] - headers = {"Authorization": f"Bearer {access_token}"} + assert login_response.cookies.get("access_token") is not None # 4. Test Change Password - Failure (Wrong current password) fail_response = await client.patch( "/auth/change-password", json={"current_password": "wrongpassword", "new_password": "newPassword456"}, - headers=headers, ) assert fail_response.status_code == 400 assert fail_response.json()["error"] == ErrorMessages.INVALID_CURRENT_PASSWORD @@ -303,7 +301,6 @@ async def test_change_password(client: AsyncClient): success_response = await client.patch( "/auth/change-password", json={"current_password": old_password, "new_password": new_password}, - headers=headers, ) assert success_response.status_code == 200 assert success_response.json()["success"] is True @@ -314,7 +311,7 @@ async def test_change_password(client: AsyncClient): "/auth/login", data={"username": email, "password": new_password} ) assert new_login_response.status_code == 200 - assert "access_token" in new_login_response.json() + assert new_login_response.cookies.get("access_token") is not None # 7. Verify Login fails with OLD password old_login_response = await client.post( diff --git a/app/tests/test_users.py b/app/tests/test_users.py index 1d82f3a..3c42a90 100644 --- a/app/tests/test_users.py +++ b/app/tests/test_users.py @@ -35,7 +35,7 @@ async def auth_client(client: AsyncClient) -> AsyncClient: ) await session.commit() - # Login and get token + # Login — access_token is set as HttpOnly cookie; httpx stores and forwards it automatically response = await client.post( "/auth/login", data={ @@ -43,11 +43,8 @@ async def auth_client(client: AsyncClient) -> AsyncClient: "password": "password123", }, ) - data = response.json() - token = data["access_token"] - - # Set auth header - client.headers["Authorization"] = f"Bearer {token}" + assert response.status_code == 200 + assert response.cookies.get("access_token") is not None return client From 2c5d5759f4a838f8593d946b0e28e6c4dffc0e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 8 Mar 2026 11:58:57 +0300 Subject: [PATCH 6/6] refactor: Remove LoginResponse class from token schema --- app/schemas/token.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/schemas/token.py b/app/schemas/token.py index 3b9b037..1e30a39 100644 --- a/app/schemas/token.py +++ b/app/schemas/token.py @@ -10,10 +10,6 @@ class Token(BaseModel): message: str | None = None -class LoginResponse(Token): - user: UserPublic - - class AuthTokens(Token): refresh_token: str user: UserPublic