From f98e83500a8dbbbacbb7838b823f03e4b0759198 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Wed, 23 Apr 2025 20:19:44 +0200 Subject: [PATCH 01/13] WIP: use HTTP-only cookie for authentication instead of sending the token in plain text --- backend/app/api/deps.py | 29 ++++++++++---- backend/app/api/routes/login.py | 16 ++++---- backend/app/api/routes/users.py | 1 + backend/app/core/security.py | 22 +++++++++-- frontend/src/client/core/ApiRequestOptions.ts | 1 + frontend/src/client/core/request.ts | 39 +++++++++++++++---- frontend/src/client/sdk.gen.ts | 25 +++++++++--- frontend/src/client/types.gen.ts | 5 ++- 8 files changed, 105 insertions(+), 33 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c2b83c841d7..330130f411c 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,9 +1,9 @@ from collections.abc import Generator -from typing import Annotated +from typing import Annotated, Optional import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer +from fastapi import Depends, HTTPException, status, Cookie +from fastapi.security import OAuth2PasswordBearer, APIKeyCookie from jwt.exceptions import InvalidTokenError from pydantic import ValidationError from sqlmodel import Session @@ -16,7 +16,7 @@ reusable_oauth2 = OAuth2PasswordBearer( tokenUrl=f"{settings.API_V1_STR}/login/access-token" ) - +cookie_scheme = APIKeyCookie(name="http_only_auth_cookie") def get_db() -> Generator[Session, None, None]: with Session(engine) as session: @@ -27,27 +27,42 @@ def get_db() -> Generator[Session, None, None]: TokenDep = Annotated[str, Depends(reusable_oauth2)] -def get_current_user(session: SessionDep, token: TokenDep) -> User: +def get_current_user( + session: SessionDep, + http_only_auth_cookie: str = Depends(cookie_scheme), +) -> User: + print("start get_current_user...") + if not http_only_auth_cookie: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + try: payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + http_only_auth_cookie, + settings.SECRET_KEY, + algorithms=[security.ALGORITHM] ) token_data = TokenPayload(**payload) + print(f"get_current_user token data: {token_data}") except (InvalidTokenError, ValidationError): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Could not validate credentials", ) + user = session.get(User, token_data.sub) if not user: raise HTTPException(status_code=404, detail="User not found") if not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") + return user CurrentUser = Annotated[User, Depends(get_current_user)] - +print(f"CurrentUser {CurrentUser}") def get_current_active_superuser(current_user: CurrentUser) -> User: if not current_user.is_superuser: diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 980c66f86fa..bc7429e548a 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -2,9 +2,8 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.security import OAuth2PasswordRequestForm - from app import crud from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser from app.core import security @@ -24,7 +23,7 @@ @router.post("/login/access-token") def login_access_token( session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] -) -> Token: +) -> JSONResponse: """ OAuth2 compatible token login, get an access token for future requests """ @@ -36,11 +35,12 @@ def login_access_token( elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - return Token( - access_token=security.create_access_token( - user.id, expires_delta=access_token_expires - ) - ) + return security.set_response_cookie(user.id, access_token_expires) + # return Token( + # access_token=security.create_access_token( + # user.id, expires_delta=access_token_expires + # )) + @router.post("/login/test-token", response_model=UserPublic) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 6429818458a..5eddfb99a27 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -122,6 +122,7 @@ def read_user_me(current_user: CurrentUser) -> Any: """ Get current user. """ + print("read_user_me!!!!!!!") return current_user diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 7aff7cfb329..ac2012072ca 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,14 +1,13 @@ from datetime import datetime, timedelta, timezone from typing import Any - import jwt from passlib.context import CryptContext - +from fastapi.responses import JSONResponse +from fastapi import Response from app.core.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - ALGORITHM = "HS256" @@ -19,6 +18,23 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: return encoded_jwt +def set_response_cookie(subject: str | Any, expires_delta: timedelta) -> Response: + access_token = create_access_token(subject, expires_delta) + response = JSONResponse( + content={"message": "Login successful"} + ) + response.set_cookie( + key="http_only_auth_cookie", + value=access_token, + httponly=True, + max_age=3600, + expires=3600, + samesite="lax", + secure=False, + ) + return response + + def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) diff --git a/frontend/src/client/core/ApiRequestOptions.ts b/frontend/src/client/core/ApiRequestOptions.ts index d1136f428ba..98b86b07836 100644 --- a/frontend/src/client/core/ApiRequestOptions.ts +++ b/frontend/src/client/core/ApiRequestOptions.ts @@ -18,4 +18,5 @@ export type ApiRequestOptions = { readonly responseHeader?: string readonly responseTransformer?: (data: unknown) => Promise readonly url: string + readonly withCredentials?: boolean } diff --git a/frontend/src/client/core/request.ts b/frontend/src/client/core/request.ts index 8b42272b93e..7cfed260ca9 100644 --- a/frontend/src/client/core/request.ts +++ b/frontend/src/client/core/request.ts @@ -132,13 +132,9 @@ export const getHeaders = async ( options: ApiRequestOptions, ): Promise> => { const [token, username, password, additionalHeaders] = await Promise.all([ - // @ts-ignore resolve(options, config.TOKEN), - // @ts-ignore resolve(options, config.USERNAME), - // @ts-ignore resolve(options, config.PASSWORD), - // @ts-ignore resolve(options, config.HEADERS), ]) @@ -178,6 +174,8 @@ export const getHeaders = async ( } else if (options.formData !== undefined) { if (options.mediaType) { headers["Content-Type"] = options.mediaType + } else { + headers["Content-Type"] = "application/x-www-form-urlencoded" } } @@ -203,15 +201,41 @@ export const sendRequest = async ( ): Promise> => { const controller = new AbortController() + // Properly handle form data for URL-encoded submissions + let data = body; + + // If we have formData but it's not a FormData instance, + // and Content-Type is application/x-www-form-urlencoded + if (options.formData && !isFormData(options.formData) && + headers["Content-Type"] === "application/x-www-form-urlencoded") { + // Use URLSearchParams or axios's built-in handling for url-encoded data + const params = new URLSearchParams(); + Object.entries(options.formData).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + params.append(key, String(value)); + } + }); + data = params; + } else { + data = body ?? formData; + } + let requestConfig: AxiosRequestConfig = { - data: body ?? formData, + data: data, headers, method: options.method, signal: controller.signal, url, - withCredentials: config.WITH_CREDENTIALS, + withCredentials: options.withCredentials ?? config.WITH_CREDENTIALS, } + console.log("Request config:", JSON.stringify({ + url: requestConfig.url, + method: requestConfig.method, + headers: requestConfig.headers, + withCredentials: requestConfig.withCredentials + })); + onCancel(() => controller.abort()) for (const fn of config.interceptors.request._fns) { @@ -367,7 +391,6 @@ export const request = ( if (options.responseTransformer && isSuccess(response.status)) { transformedBody = await options.responseTransformer(responseBody) } - const result: ApiResult = { url, ok: isSuccess(response.status), @@ -375,7 +398,7 @@ export const request = ( statusText: response.statusText, body: responseHeader ?? transformedBody, } - + console.log(result) catchErrorCodes(options, result) resolve(result.body) diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 92ded2bde8f..9f5cbec3030 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -183,7 +183,11 @@ export class LoginService { method: "POST", url: "/api/v1/login/access-token", formData: data.formData, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, mediaType: "application/x-www-form-urlencoded", + withCredentials: true, errors: { 422: "Validation Error", }, @@ -200,6 +204,7 @@ export class LoginService { return __request(OpenAPI, { method: "POST", url: "/api/v1/login/test-token", + withCredentials: true, }) } @@ -326,12 +331,20 @@ export class UsersService { * @returns UserPublic Successful Response * @throws ApiError */ - public static readUserMe(): CancelablePromise { - return __request(OpenAPI, { - method: "GET", - url: "/api/v1/users/me", - }) - } + // public static readUserMe(): CancelablePromise { + // console.log("readUserMe") + // let r = __request(OpenAPI, { + // method: "GET", + // url: "/api/v1/users/me", + // withCredentials: true, + // }) + // console.log(r.promise) + // return __request(OpenAPI, { + // method: "GET", + // url: "/api/v1/users/me", + // withCredentials: true, + // }) + // } /** * Delete User Me diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index c2a58d06cbc..92038eef10b 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -49,6 +49,9 @@ export type Token = { token_type?: string } +export type HTTPOnlyCookie = { + message: string +} export type UpdatePassword = { current_password: string new_password: string @@ -136,7 +139,7 @@ export type LoginLoginAccessTokenData = { formData: Body_login_login_access_token } -export type LoginLoginAccessTokenResponse = Token +export type LoginLoginAccessTokenResponse = HTTPOnlyCookie export type LoginTestTokenResponse = UserPublic From eef9b87d0200f53095156675d6110dc0cc880706 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Wed, 23 Apr 2025 21:09:02 +0200 Subject: [PATCH 02/13] Implement logout function in both backend and frontend to delete HTTP-only cookie on logout --- backend/app/api/deps.py | 3 -- backend/app/api/routes/login.py | 30 ++++++++++++----- backend/app/api/routes/users.py | 1 - frontend/src/client/sdk.gen.ts | 57 ++++++++++++++++++++++++-------- frontend/src/client/types.gen.ts | 2 ++ frontend/src/hooks/useAuth.ts | 12 +++++-- 6 files changed, 76 insertions(+), 29 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 330130f411c..77bb9320f85 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -31,7 +31,6 @@ def get_current_user( session: SessionDep, http_only_auth_cookie: str = Depends(cookie_scheme), ) -> User: - print("start get_current_user...") if not http_only_auth_cookie: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -45,7 +44,6 @@ def get_current_user( algorithms=[security.ALGORITHM] ) token_data = TokenPayload(**payload) - print(f"get_current_user token data: {token_data}") except (InvalidTokenError, ValidationError): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -62,7 +60,6 @@ def get_current_user( CurrentUser = Annotated[User, Depends(get_current_user)] -print(f"CurrentUser {CurrentUser}") def get_current_active_superuser(current_user: CurrentUser) -> User: if not current_user.is_superuser: diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index bc7429e548a..e19f74e5540 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from fastapi.security import OAuth2PasswordRequestForm from app import crud -from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser +from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser, get_current_user from app.core import security from app.core.config import settings from app.core.security import get_password_hash @@ -22,10 +22,10 @@ @router.post("/login/access-token") def login_access_token( - session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] + session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] ) -> JSONResponse: """ - OAuth2 compatible token login, get an access token for future requests + OAuth2 compatible token login, get an access token for future requests (sent in a HTTP-only cookie) """ user = crud.authenticate( session=session, email=form_data.username, password=form_data.password @@ -36,11 +36,6 @@ def login_access_token( raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) return security.set_response_cookie(user.id, access_token_expires) - # return Token( - # access_token=security.create_access_token( - # user.id, expires_delta=access_token_expires - # )) - @router.post("/login/test-token", response_model=UserPublic) @@ -122,3 +117,22 @@ def recover_password_html_content(email: str, session: SessionDep) -> Any: return HTMLResponse( content=email_data.html_content, headers={"subject:": email_data.subject} ) + + +@router.post("/logout", dependencies=[Depends(get_current_user)]) +def logout() -> JSONResponse: + """ + Delete the HTTP-only cookie during logout + """ + + response = JSONResponse(content={"message": "Logout successful"}) + + response.delete_cookie( + key="http_only_auth_cookie", + path="/", + domain=None, + httponly=True, + samesite="lax", + secure=False, # Should be True in production + ) + return response diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 5eddfb99a27..6429818458a 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -122,7 +122,6 @@ def read_user_me(current_user: CurrentUser) -> Any: """ Get current user. """ - print("read_user_me!!!!!!!") return current_user diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 9f5cbec3030..5d952a0a72c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -15,6 +15,7 @@ import type { ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, + LogoutResponse, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, @@ -66,6 +67,7 @@ export class ItemsService { skip: data.skip, limit: data.limit, }, + withCredentials: true, errors: { 422: "Validation Error", }, @@ -88,6 +90,7 @@ export class ItemsService { url: "/api/v1/items/", body: data.requestBody, mediaType: "application/json", + withCredentials: true, errors: { 422: "Validation Error", }, @@ -111,6 +114,7 @@ export class ItemsService { path: { id: data.id, }, + withCredentials: true, errors: { 422: "Validation Error", }, @@ -135,6 +139,7 @@ export class ItemsService { path: { id: data.id, }, + withCredentials: true, body: data.requestBody, mediaType: "application/json", errors: { @@ -160,6 +165,7 @@ export class ItemsService { path: { id: data.id, }, + withCredentials: true, errors: { 422: "Validation Error", }, @@ -194,6 +200,31 @@ export class LoginService { }) } + + /** + * Login Access Token + * OAuth2 compatible token login, get an access token for future requests + * @param data The data for the request. + * @param data.formData + * @returns Token Successful Response + * @throws ApiError + */ + public static logout(): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/api/v1/logout", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + mediaType: "application/x-www-form-urlencoded", + withCredentials: true, + errors: { + 422: "Validation Error", + }, + }) + } + + /** * Test Token * Test access token @@ -331,20 +362,13 @@ export class UsersService { * @returns UserPublic Successful Response * @throws ApiError */ - // public static readUserMe(): CancelablePromise { - // console.log("readUserMe") - // let r = __request(OpenAPI, { - // method: "GET", - // url: "/api/v1/users/me", - // withCredentials: true, - // }) - // console.log(r.promise) - // return __request(OpenAPI, { - // method: "GET", - // url: "/api/v1/users/me", - // withCredentials: true, - // }) - // } + public static readUserMe(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/users/me", + withCredentials: true, + }) + } /** * Delete User Me @@ -356,6 +380,7 @@ export class UsersService { return __request(OpenAPI, { method: "DELETE", url: "/api/v1/users/me", + withCredentials: true, }) } @@ -375,6 +400,7 @@ export class UsersService { url: "/api/v1/users/me", body: data.requestBody, mediaType: "application/json", + withCredentials: true, errors: { 422: "Validation Error", }, @@ -397,6 +423,7 @@ export class UsersService { url: "/api/v1/users/me/password", body: data.requestBody, mediaType: "application/json", + withCredentials: true, errors: { 422: "Validation Error", }, @@ -468,6 +495,7 @@ export class UsersService { }, body: data.requestBody, mediaType: "application/json", + withCredentials: true, errors: { 422: "Validation Error", }, @@ -488,6 +516,7 @@ export class UsersService { return __request(OpenAPI, { method: "DELETE", url: "/api/v1/users/{user_id}", + withCredentials: true, path: { user_id: data.userId, }, diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 92038eef10b..271d8f0808a 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -141,6 +141,8 @@ export type LoginLoginAccessTokenData = { export type LoginLoginAccessTokenResponse = HTTPOnlyCookie +export type LogoutResponse = HTTPOnlyCookie + export type LoginTestTokenResponse = UserPublic export type LoginRecoverPasswordData = { diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 5344493d490..ee78c1d4878 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -58,9 +58,15 @@ const useAuth = () => { }, }) - const logout = () => { - localStorage.removeItem("access_token") - navigate({ to: "/login" }) + const logout = async () => { + try { + await LoginService.logout(); + localStorage.removeItem("access_token"); + navigate({ to: "/login" }); + } catch (error) { + console.error("Logout failed:", error); + navigate({ to: "/login" }); + } } return { From 52512d405b01c3d13635b54432a2080ea7162972 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Wed, 23 Apr 2025 21:24:12 +0200 Subject: [PATCH 03/13] Store is_authenticated state in localStorage instead of access_token --- frontend/src/hooks/useAuth.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index ee78c1d4878..3d447d04995 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -13,7 +13,7 @@ import { import { handleError } from "@/utils" const isLoggedIn = () => { - return localStorage.getItem("access_token") !== null + return localStorage.getItem("is_authenticated") !== null } const useAuth = () => { @@ -45,7 +45,7 @@ const useAuth = () => { const response = await LoginService.loginAccessToken({ formData: data, }) - localStorage.setItem("access_token", response.access_token) + localStorage.setItem("is_authenticated", "true") } const loginMutation = useMutation({ @@ -61,7 +61,7 @@ const useAuth = () => { const logout = async () => { try { await LoginService.logout(); - localStorage.removeItem("access_token"); + localStorage.removeItem("is_authenticated"); navigate({ to: "/login" }); } catch (error) { console.error("Logout failed:", error); From 1c7f76b55f382490119f9f1af241257f5de881e8 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Wed, 23 Apr 2025 21:28:56 +0200 Subject: [PATCH 04/13] Update a test case --- frontend/tests/login.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/tests/login.spec.ts b/frontend/tests/login.spec.ts index e4829349162..b36d002f5e9 100644 --- a/frontend/tests/login.spec.ts +++ b/frontend/tests/login.spec.ts @@ -116,11 +116,9 @@ test("Logged-out user cannot access protected routes", async ({ page }) => { await page.waitForURL("/login") }) -test("Redirects to /login when token is wrong", async ({ page }) => { - await page.goto("/settings") - await page.evaluate(() => { - localStorage.setItem("access_token", "invalid_token") - }) +test("Redirects to /login when authentication is invalid", async ({ page }) => { + await page.context().clearCookies() + await page.goto("/settings") await page.waitForURL("/login") await expect(page).toHaveURL("/login") From f7fed4f13b72f7a55d6efd77cd481fdd179c8ad5 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Thu, 24 Apr 2025 14:07:41 +0200 Subject: [PATCH 05/13] Fix the automatic logout functionality when the backend returns 401 or 403 error code --- backend/app/api/deps.py | 2 +- backend/app/api/routes/login.py | 17 +++-------------- backend/app/core/security.py | 20 ++++++++++++++++++-- frontend/src/client/sdk.gen.ts | 1 + frontend/src/main.tsx | 5 +---- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 77bb9320f85..415b1f28218 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,5 +1,5 @@ from collections.abc import Generator -from typing import Annotated, Optional +from typing import Annotated import jwt from fastapi import Depends, HTTPException, status, Cookie diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index e19f74e5540..aeda975f62e 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -25,7 +25,7 @@ def login_access_token( session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] ) -> JSONResponse: """ - OAuth2 compatible token login, get an access token for future requests (sent in a HTTP-only cookie) + OAuth2-compatible token login: get an access token for future requests (sent in an HTTP-only cookie) """ user = crud.authenticate( session=session, email=form_data.username, password=form_data.password @@ -35,7 +35,7 @@ def login_access_token( elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - return security.set_response_cookie(user.id, access_token_expires) + return security.set_auth_cookie(user.id, access_token_expires) @router.post("/login/test-token", response_model=UserPublic) @@ -124,15 +124,4 @@ def logout() -> JSONResponse: """ Delete the HTTP-only cookie during logout """ - - response = JSONResponse(content={"message": "Logout successful"}) - - response.delete_cookie( - key="http_only_auth_cookie", - path="/", - domain=None, - httponly=True, - samesite="lax", - secure=False, # Should be True in production - ) - return response + return security.delete_auth_cookie() diff --git a/backend/app/core/security.py b/backend/app/core/security.py index ac2012072ca..a86f926769c 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,3 +1,4 @@ +import os from datetime import datetime, timedelta, timezone from typing import Any import jwt @@ -18,11 +19,12 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: return encoded_jwt -def set_response_cookie(subject: str | Any, expires_delta: timedelta) -> Response: +def set_auth_cookie(subject: str | Any, expires_delta: timedelta) -> Response: access_token = create_access_token(subject, expires_delta) response = JSONResponse( content={"message": "Login successful"} ) + # Note: The secure flag on cookies ensures they're only sent over encrypted HTTPS connections. For local development (HTTP) set it to False response.set_cookie( key="http_only_auth_cookie", value=access_token, @@ -30,7 +32,21 @@ def set_response_cookie(subject: str | Any, expires_delta: timedelta) -> Respons max_age=3600, expires=3600, samesite="lax", - secure=False, + secure=True, + ) + return response + + +def delete_auth_cookie() -> Response: + response = JSONResponse(content={"message": "Logout successful"}) + + response.delete_cookie( + key="http_only_auth_cookie", + path="/", + domain=None, + httponly=True, + samesite="lax", + secure=False, # Should be True in production ) return response diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 5d952a0a72c..722d76e20eb 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -232,6 +232,7 @@ export class LoginService { * @throws ApiError */ public static testToken(): CancelablePromise { + console.log("test token") return __request(OpenAPI, { method: "POST", url: "/api/v1/login/test-token", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0d80ea6f8dc..b20c68a4d3d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -13,13 +13,10 @@ import { ApiError, OpenAPI } from "./client" import { CustomProvider } from "./components/ui/provider" OpenAPI.BASE = import.meta.env.VITE_API_URL -OpenAPI.TOKEN = async () => { - return localStorage.getItem("access_token") || "" -} const handleApiError = (error: Error) => { if (error instanceof ApiError && [401, 403].includes(error.status)) { - localStorage.removeItem("access_token") + localStorage.removeItem("is_authenticated") window.location.href = "/login" } } From fc6e2163fbae65620717f92a491066594b674a68 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Thu, 24 Apr 2025 17:46:02 +0200 Subject: [PATCH 06/13] Fix lint errors --- backend/app/api/deps.py | 14 +++++++------- backend/app/api/routes/items.py | 1 + backend/app/api/routes/login.py | 16 ++++++++++++---- backend/app/api/routes/users.py | 6 ++++-- backend/app/core/security.py | 12 ++++++------ 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 415b1f28218..d8d166aad0b 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -2,8 +2,8 @@ from typing import Annotated import jwt -from fastapi import Depends, HTTPException, status, Cookie -from fastapi.security import OAuth2PasswordBearer, APIKeyCookie +from fastapi import Depends, HTTPException, status +from fastapi.security import APIKeyCookie, OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError from pydantic import ValidationError from sqlmodel import Session @@ -18,6 +18,7 @@ ) cookie_scheme = APIKeyCookie(name="http_only_auth_cookie") + def get_db() -> Generator[Session, None, None]: with Session(engine) as session: yield session @@ -28,8 +29,8 @@ def get_db() -> Generator[Session, None, None]: def get_current_user( - session: SessionDep, - http_only_auth_cookie: str = Depends(cookie_scheme), + session: SessionDep, + http_only_auth_cookie: str = Depends(cookie_scheme), ) -> User: if not http_only_auth_cookie: raise HTTPException( @@ -39,9 +40,7 @@ def get_current_user( try: payload = jwt.decode( - http_only_auth_cookie, - settings.SECRET_KEY, - algorithms=[security.ALGORITHM] + http_only_auth_cookie, settings.SECRET_KEY, algorithms=[security.ALGORITHM] ) token_data = TokenPayload(**payload) except (InvalidTokenError, ValidationError): @@ -61,6 +60,7 @@ def get_current_user( CurrentUser = Annotated[User, Depends(get_current_user)] + def get_current_active_superuser(current_user: CurrentUser) -> User: if not current_user.is_superuser: raise HTTPException( diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index 177dc1e4760..feed3041a9e 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -100,6 +100,7 @@ def delete_item( Delete an item. """ item = session.get(Item, id) + print(current_user) if not item: raise HTTPException(status_code=404, detail="Item not found") if not current_user.is_superuser and (item.owner_id != current_user.id): diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index aeda975f62e..179541c63f2 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -4,12 +4,18 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import HTMLResponse, JSONResponse from fastapi.security import OAuth2PasswordRequestForm + from app import crud -from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser, get_current_user +from app.api.deps import ( + CurrentUser, + SessionDep, + get_current_active_superuser, + get_current_user, +) from app.core import security from app.core.config import settings from app.core.security import get_password_hash -from app.models import Message, NewPassword, Token, UserPublic +from app.models import Message, NewPassword, UserPublic from app.utils import ( generate_password_reset_token, generate_reset_password_email, @@ -22,7 +28,7 @@ @router.post("/login/access-token") def login_access_token( - session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] + session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] ) -> JSONResponse: """ OAuth2-compatible token login: get an access token for future requests (sent in an HTTP-only cookie) @@ -35,7 +41,9 @@ def login_access_token( elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - return security.set_auth_cookie(user.id, access_token_expires) + r = security.set_auth_cookie(user.id, access_token_expires) + print(r) + return r @router.post("/login/test-token", response_model=UserPublic) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 6429818458a..3a5f3100ea9 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -9,6 +9,7 @@ CurrentUser, SessionDep, get_current_active_superuser, + get_current_user, ) from app.core.config import settings from app.core.security import get_password_hash, verify_password @@ -117,15 +118,16 @@ def update_password_me( return Message(message="Password updated successfully") -@router.get("/me", response_model=UserPublic) +@router.get("/me", response_model=UserPublic, dependencies=[Depends(get_current_user)]) def read_user_me(current_user: CurrentUser) -> Any: """ Get current user. """ + print(current_user) return current_user -@router.delete("/me", response_model=Message) +@router.delete("/me", response_model=Message, dependencies=[Depends(get_current_user)]) def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: """ Delete own user. diff --git a/backend/app/core/security.py b/backend/app/core/security.py index a86f926769c..3ef49755401 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,10 +1,11 @@ -import os from datetime import datetime, timedelta, timezone from typing import Any + import jwt -from passlib.context import CryptContext -from fastapi.responses import JSONResponse from fastapi import Response +from fastapi.responses import JSONResponse +from passlib.context import CryptContext + from app.core.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -21,9 +22,7 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: def set_auth_cookie(subject: str | Any, expires_delta: timedelta) -> Response: access_token = create_access_token(subject, expires_delta) - response = JSONResponse( - content={"message": "Login successful"} - ) + response = JSONResponse(content={"message": "Login successful"}) # Note: The secure flag on cookies ensures they're only sent over encrypted HTTPS connections. For local development (HTTP) set it to False response.set_cookie( key="http_only_auth_cookie", @@ -33,6 +32,7 @@ def set_auth_cookie(subject: str | Any, expires_delta: timedelta) -> Response: expires=3600, samesite="lax", secure=True, + domain=None, ) return response From 9c21d6488354e52cf669a9b35302088bc0283b08 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Sat, 26 Apr 2025 12:32:20 +0200 Subject: [PATCH 07/13] Update backend tests to use a cookie instead of the token header --- backend/app/tests/api/routes/test_items.py | 44 ++++----- backend/app/tests/api/routes/test_login.py | 37 +++++--- backend/app/tests/api/routes/test_users.py | 101 ++++++++++----------- backend/app/tests/conftest.py | 8 +- backend/app/tests/utils/user.py | 7 +- backend/app/tests/utils/utils.py | 20 +++- 6 files changed, 114 insertions(+), 103 deletions(-) diff --git a/backend/app/tests/api/routes/test_items.py b/backend/app/tests/api/routes/test_items.py index c215238a690..8e1a7e31ac8 100644 --- a/backend/app/tests/api/routes/test_items.py +++ b/backend/app/tests/api/routes/test_items.py @@ -8,12 +8,12 @@ def test_create_item( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: data = {"title": "Foo", "description": "Fighters"} response = client.post( f"{settings.API_V1_STR}/items/", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert response.status_code == 200 @@ -25,12 +25,12 @@ def test_create_item( def test_read_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: item = create_random_item(db) response = client.get( f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert response.status_code == 200 content = response.json() @@ -41,11 +41,11 @@ def test_read_item( def test_read_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: response = client.get( f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert response.status_code == 404 content = response.json() @@ -53,12 +53,12 @@ def test_read_item_not_found( def test_read_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session + client: TestClient, normal_user_auth_cookies: dict[str, str], db: Session ) -> None: item = create_random_item(db) response = client.get( f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, ) assert response.status_code == 400 content = response.json() @@ -66,13 +66,13 @@ def test_read_item_not_enough_permissions( def test_read_items( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: create_random_item(db) create_random_item(db) response = client.get( f"{settings.API_V1_STR}/items/", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert response.status_code == 200 content = response.json() @@ -80,13 +80,13 @@ def test_read_items( def test_update_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: item = create_random_item(db) data = {"title": "Updated title", "description": "Updated description"} response = client.put( f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert response.status_code == 200 @@ -98,12 +98,12 @@ def test_update_item( def test_update_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: data = {"title": "Updated title", "description": "Updated description"} response = client.put( f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert response.status_code == 404 @@ -112,13 +112,13 @@ def test_update_item_not_found( def test_update_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session + client: TestClient, normal_user_auth_cookies: dict[str, str], db: Session ) -> None: item = create_random_item(db) data = {"title": "Updated title", "description": "Updated description"} response = client.put( f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, json=data, ) assert response.status_code == 400 @@ -127,12 +127,12 @@ def test_update_item_not_enough_permissions( def test_delete_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: item = create_random_item(db) response = client.delete( f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert response.status_code == 200 content = response.json() @@ -140,11 +140,11 @@ def test_delete_item( def test_delete_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: response = client.delete( f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert response.status_code == 404 content = response.json() @@ -152,12 +152,12 @@ def test_delete_item_not_found( def test_delete_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session + client: TestClient, normal_user_auth_cookies: dict[str, str], db: Session ) -> None: item = create_random_item(db) response = client.delete( f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, ) assert response.status_code == 400 content = response.json() diff --git a/backend/app/tests/api/routes/test_login.py b/backend/app/tests/api/routes/test_login.py index 80fa7879797..d1723ff0b1d 100644 --- a/backend/app/tests/api/routes/test_login.py +++ b/backend/app/tests/api/routes/test_login.py @@ -12,19 +12,25 @@ from app.utils import generate_password_reset_token -def test_get_access_token(client: TestClient) -> None: +def test_get_auth_cookie(client: TestClient) -> None: login_data = { "username": settings.FIRST_SUPERUSER, "password": settings.FIRST_SUPERUSER_PASSWORD, } + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) - tokens = r.json() + assert r.status_code == 200 - assert "access_token" in tokens - assert tokens["access_token"] + assert r.json()["message"] == "Login successful" + + cookie_header = r.headers.get("Set-Cookie") + assert "http_only_auth_cookie=" in cookie_header + + cookie_value = cookie_header.split("http_only_auth_cookie=")[1].split(";")[0] + assert cookie_value -def test_get_access_token_incorrect_password(client: TestClient) -> None: +def test_get_auth_cookie_incorrect_password(client: TestClient) -> None: login_data = { "username": settings.FIRST_SUPERUSER, "password": "incorrect", @@ -33,12 +39,12 @@ def test_get_access_token_incorrect_password(client: TestClient) -> None: assert r.status_code == 400 -def test_use_access_token( - client: TestClient, superuser_token_headers: dict[str, str] +def test_use_auth_cookie( + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: r = client.post( f"{settings.API_V1_STR}/login/test-token", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) result = r.json() assert r.status_code == 200 @@ -46,7 +52,7 @@ def test_use_access_token( def test_recovery_password( - client: TestClient, normal_user_token_headers: dict[str, str] + client: TestClient, normal_user_auth_cookies: dict[str, str] ) -> None: with ( patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"), @@ -55,23 +61,24 @@ def test_recovery_password( email = "test@example.com" r = client.post( f"{settings.API_V1_STR}/password-recovery/{email}", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, ) assert r.status_code == 200 assert r.json() == {"message": "Password recovery email sent"} def test_recovery_password_user_not_exits( - client: TestClient, normal_user_token_headers: dict[str, str] + client: TestClient, normal_user_auth_cookies: dict[str, str] ) -> None: email = "jVgQr@example.com" r = client.post( f"{settings.API_V1_STR}/password-recovery/{email}", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, ) assert r.status_code == 404 +# TODO def test_reset_password(client: TestClient, db: Session) -> None: email = random_email() password = random_lower_string() @@ -91,7 +98,7 @@ def test_reset_password(client: TestClient, db: Session) -> None: r = client.post( f"{settings.API_V1_STR}/reset-password/", - headers=headers, + cookies=headers, json=data, ) @@ -103,12 +110,12 @@ def test_reset_password(client: TestClient, db: Session) -> None: def test_reset_password_invalid_token( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: data = {"new_password": "changethis", "token": "invalid"} r = client.post( f"{settings.API_V1_STR}/reset-password/", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) response = r.json() diff --git a/backend/app/tests/api/routes/test_users.py b/backend/app/tests/api/routes/test_users.py index ba9be654262..691788520a5 100644 --- a/backend/app/tests/api/routes/test_users.py +++ b/backend/app/tests/api/routes/test_users.py @@ -8,13 +8,13 @@ from app.core.config import settings from app.core.security import verify_password from app.models import User, UserCreate -from app.tests.utils.utils import random_email, random_lower_string +from app.tests.utils.utils import extract_cookies, random_email, random_lower_string def test_get_users_superuser_me( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: - r = client.get(f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers) + r = client.get(f"{settings.API_V1_STR}/users/me", cookies=superuser_auth_cookies) current_user = r.json() assert current_user assert current_user["is_active"] is True @@ -23,9 +23,9 @@ def test_get_users_superuser_me( def test_get_users_normal_user_me( - client: TestClient, normal_user_token_headers: dict[str, str] + client: TestClient, normal_user_auth_cookies: dict[str, str] ) -> None: - r = client.get(f"{settings.API_V1_STR}/users/me", headers=normal_user_token_headers) + r = client.get(f"{settings.API_V1_STR}/users/me", cookies=normal_user_auth_cookies) current_user = r.json() assert current_user assert current_user["is_active"] is True @@ -34,7 +34,7 @@ def test_get_users_normal_user_me( def test_create_user_new_email( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: with ( patch("app.utils.send_email", return_value=None), @@ -46,7 +46,7 @@ def test_create_user_new_email( data = {"email": username, "password": password} r = client.post( f"{settings.API_V1_STR}/users/", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert 200 <= r.status_code < 300 @@ -57,7 +57,7 @@ def test_create_user_new_email( def test_get_existing_user( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: username = random_email() password = random_lower_string() @@ -66,7 +66,7 @@ def test_get_existing_user( user_id = user.id r = client.get( f"{settings.API_V1_STR}/users/{user_id}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert 200 <= r.status_code < 300 api_user = r.json() @@ -87,13 +87,11 @@ def test_get_existing_user_current_user(client: TestClient, db: Session) -> None "password": password, } r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) - tokens = r.json() - a_token = tokens["access_token"] - headers = {"Authorization": f"Bearer {a_token}"} + cookies = extract_cookies(r) r = client.get( f"{settings.API_V1_STR}/users/{user_id}", - headers=headers, + cookies=cookies, ) assert 200 <= r.status_code < 300 api_user = r.json() @@ -103,18 +101,18 @@ def test_get_existing_user_current_user(client: TestClient, db: Session) -> None def test_get_existing_user_permissions_error( - client: TestClient, normal_user_token_headers: dict[str, str] + client: TestClient, normal_user_auth_cookies: dict[str, str] ) -> None: r = client.get( f"{settings.API_V1_STR}/users/{uuid.uuid4()}", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, ) assert r.status_code == 403 assert r.json() == {"detail": "The user doesn't have enough privileges"} def test_create_user_existing_username( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: username = random_email() # username = email @@ -124,7 +122,7 @@ def test_create_user_existing_username( data = {"email": username, "password": password} r = client.post( f"{settings.API_V1_STR}/users/", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) created_user = r.json() @@ -133,21 +131,21 @@ def test_create_user_existing_username( def test_create_user_by_normal_user( - client: TestClient, normal_user_token_headers: dict[str, str] + client: TestClient, normal_user_auth_cookies: dict[str, str] ) -> None: username = random_email() password = random_lower_string() data = {"email": username, "password": password} r = client.post( f"{settings.API_V1_STR}/users/", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, json=data, ) assert r.status_code == 403 def test_retrieve_users( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: username = random_email() password = random_lower_string() @@ -159,7 +157,7 @@ def test_retrieve_users( user_in2 = UserCreate(email=username2, password=password2) crud.create_user(session=db, user_create=user_in2) - r = client.get(f"{settings.API_V1_STR}/users/", headers=superuser_token_headers) + r = client.get(f"{settings.API_V1_STR}/users/", cookies=superuser_auth_cookies) all_users = r.json() assert len(all_users["data"]) > 1 @@ -169,14 +167,14 @@ def test_retrieve_users( def test_update_user_me( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session + client: TestClient, normal_user_auth_cookies: dict[str, str], db: Session ) -> None: full_name = "Updated Name" email = random_email() data = {"full_name": full_name, "email": email} r = client.patch( f"{settings.API_V1_STR}/users/me", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, json=data, ) assert r.status_code == 200 @@ -192,7 +190,7 @@ def test_update_user_me( def test_update_password_me( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: new_password = random_lower_string() data = { @@ -201,7 +199,7 @@ def test_update_password_me( } r = client.patch( f"{settings.API_V1_STR}/users/me/password", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert r.status_code == 200 @@ -221,7 +219,7 @@ def test_update_password_me( } r = client.patch( f"{settings.API_V1_STR}/users/me/password", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=old_data, ) db.refresh(user_db) @@ -231,13 +229,13 @@ def test_update_password_me( def test_update_password_me_incorrect_password( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: new_password = random_lower_string() data = {"current_password": new_password, "new_password": new_password} r = client.patch( f"{settings.API_V1_STR}/users/me/password", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert r.status_code == 400 @@ -246,7 +244,7 @@ def test_update_password_me_incorrect_password( def test_update_user_me_email_exists( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session + client: TestClient, normal_user_auth_cookies: dict[str, str], db: Session ) -> None: username = random_email() password = random_lower_string() @@ -256,7 +254,7 @@ def test_update_user_me_email_exists( data = {"email": user.email} r = client.patch( f"{settings.API_V1_STR}/users/me", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, json=data, ) assert r.status_code == 409 @@ -264,7 +262,7 @@ def test_update_user_me_email_exists( def test_update_password_me_same_password_error( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: data = { "current_password": settings.FIRST_SUPERUSER_PASSWORD, @@ -272,7 +270,7 @@ def test_update_password_me_same_password_error( } r = client.patch( f"{settings.API_V1_STR}/users/me/password", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert r.status_code == 400 @@ -321,7 +319,7 @@ def test_register_user_already_exists_error(client: TestClient) -> None: def test_update_user( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: username = random_email() password = random_lower_string() @@ -331,7 +329,7 @@ def test_update_user( data = {"full_name": "Updated_full_name"} r = client.patch( f"{settings.API_V1_STR}/users/{user.id}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert r.status_code == 200 @@ -347,12 +345,12 @@ def test_update_user( def test_update_user_not_exists( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: data = {"full_name": "Updated_full_name"} r = client.patch( f"{settings.API_V1_STR}/users/{uuid.uuid4()}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert r.status_code == 404 @@ -360,7 +358,7 @@ def test_update_user_not_exists( def test_update_user_email_exists( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: username = random_email() password = random_lower_string() @@ -375,7 +373,7 @@ def test_update_user_email_exists( data = {"email": user2.email} r = client.patch( f"{settings.API_V1_STR}/users/{user.id}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, json=data, ) assert r.status_code == 409 @@ -394,13 +392,12 @@ def test_delete_user_me(client: TestClient, db: Session) -> None: "password": password, } r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) - tokens = r.json() - a_token = tokens["access_token"] - headers = {"Authorization": f"Bearer {a_token}"} + cookies = extract_cookies(r) + print(f"COOKIE_ {cookies}") r = client.delete( f"{settings.API_V1_STR}/users/me", - headers=headers, + cookies=cookies, ) assert r.status_code == 200 deleted_user = r.json() @@ -414,11 +411,11 @@ def test_delete_user_me(client: TestClient, db: Session) -> None: def test_delete_user_me_as_superuser( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: r = client.delete( f"{settings.API_V1_STR}/users/me", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert r.status_code == 403 response = r.json() @@ -426,7 +423,7 @@ def test_delete_user_me_as_superuser( def test_delete_user_super_user( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: username = random_email() password = random_lower_string() @@ -435,7 +432,7 @@ def test_delete_user_super_user( user_id = user.id r = client.delete( f"{settings.API_V1_STR}/users/{user_id}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert r.status_code == 200 deleted_user = r.json() @@ -445,18 +442,18 @@ def test_delete_user_super_user( def test_delete_user_not_found( - client: TestClient, superuser_token_headers: dict[str, str] + client: TestClient, superuser_auth_cookies: dict[str, str] ) -> None: r = client.delete( f"{settings.API_V1_STR}/users/{uuid.uuid4()}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert r.status_code == 404 assert r.json()["detail"] == "User not found" def test_delete_user_current_super_user_error( - client: TestClient, superuser_token_headers: dict[str, str], db: Session + client: TestClient, superuser_auth_cookies: dict[str, str], db: Session ) -> None: super_user = crud.get_user_by_email(session=db, email=settings.FIRST_SUPERUSER) assert super_user @@ -464,14 +461,14 @@ def test_delete_user_current_super_user_error( r = client.delete( f"{settings.API_V1_STR}/users/{user_id}", - headers=superuser_token_headers, + cookies=superuser_auth_cookies, ) assert r.status_code == 403 assert r.json()["detail"] == "Super users are not allowed to delete themselves" def test_delete_user_without_privileges( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session + client: TestClient, normal_user_auth_cookies: dict[str, str], db: Session ) -> None: username = random_email() password = random_lower_string() @@ -480,7 +477,7 @@ def test_delete_user_without_privileges( r = client.delete( f"{settings.API_V1_STR}/users/{user.id}", - headers=normal_user_token_headers, + cookies=normal_user_auth_cookies, ) assert r.status_code == 403 assert r.json()["detail"] == "The user doesn't have enough privileges" diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 90ab39a357f..056ca5a0a30 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -9,7 +9,7 @@ from app.main import app from app.models import Item, User from app.tests.utils.user import authentication_token_from_email -from app.tests.utils.utils import get_superuser_token_headers +from app.tests.utils.utils import get_superuser_auth_cookies @pytest.fixture(scope="session", autouse=True) @@ -31,12 +31,12 @@ def client() -> Generator[TestClient, None, None]: @pytest.fixture(scope="module") -def superuser_token_headers(client: TestClient) -> dict[str, str]: - return get_superuser_token_headers(client) +def superuser_auth_cookies(client: TestClient) -> dict[str, str]: + return get_superuser_auth_cookies(client) @pytest.fixture(scope="module") -def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]: +def normal_user_auth_cookies(client: TestClient, db: Session) -> dict[str, str]: return authentication_token_from_email( client=client, email=settings.EMAIL_TEST_USER, db=db ) diff --git a/backend/app/tests/utils/user.py b/backend/app/tests/utils/user.py index 9c1b0731096..3cb81b26722 100644 --- a/backend/app/tests/utils/user.py +++ b/backend/app/tests/utils/user.py @@ -4,7 +4,7 @@ from app import crud from app.core.config import settings from app.models import User, UserCreate, UserUpdate -from app.tests.utils.utils import random_email, random_lower_string +from app.tests.utils.utils import extract_cookies, random_email, random_lower_string def user_authentication_headers( @@ -13,10 +13,7 @@ def user_authentication_headers( data = {"username": email, "password": password} r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data) - response = r.json() - auth_token = response["access_token"] - headers = {"Authorization": f"Bearer {auth_token}"} - return headers + return extract_cookies(r) def create_random_user(db: Session) -> User: diff --git a/backend/app/tests/utils/utils.py b/backend/app/tests/utils/utils.py index 184bac44d9a..6444256bb71 100644 --- a/backend/app/tests/utils/utils.py +++ b/backend/app/tests/utils/utils.py @@ -1,6 +1,7 @@ import random import string +from fastapi.responses import JSONResponse from fastapi.testclient import TestClient from app.core.config import settings @@ -14,13 +15,22 @@ def random_email() -> str: return f"{random_lower_string()}@{random_lower_string()}.com" -def get_superuser_token_headers(client: TestClient) -> dict[str, str]: +def get_superuser_auth_cookies(client: TestClient) -> dict[str, str]: login_data = { "username": settings.FIRST_SUPERUSER, "password": settings.FIRST_SUPERUSER_PASSWORD, } r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) - tokens = r.json() - a_token = tokens["access_token"] - headers = {"Authorization": f"Bearer {a_token}"} - return headers + return extract_cookies(r) + + +def extract_cookies(response: JSONResponse) -> dict[str, str]: + cookie_header = response.headers.get("Set-Cookie") + + cookie_value = None + if cookie_header and "http_only_auth_cookie=" in cookie_header: + cookie_value = cookie_header.split("http_only_auth_cookie=")[1].split(";")[0] + + assert cookie_value, "Cookie value not found" + + return {"http_only_auth_cookie": cookie_value} From c7de13418e564cd4ce003c9b3ffcde968291bb17 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Sat, 26 Apr 2025 12:57:59 +0200 Subject: [PATCH 08/13] Fix TS errors in getHeaders by adding type assertions for resolve calls --- frontend/src/client/core/request.ts | 8 ++++---- frontend/src/hooks/useAuth.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/client/core/request.ts b/frontend/src/client/core/request.ts index 7cfed260ca9..daf2403b65c 100644 --- a/frontend/src/client/core/request.ts +++ b/frontend/src/client/core/request.ts @@ -132,10 +132,10 @@ export const getHeaders = async ( options: ApiRequestOptions, ): Promise> => { const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), + resolve(options as ApiRequestOptions, config.TOKEN), + resolve(options as ApiRequestOptions, config.USERNAME), + resolve(options as ApiRequestOptions, config.PASSWORD), + resolve>(options as ApiRequestOptions>, config.HEADERS), ]) const headers = Object.entries({ diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 3d447d04995..f22d78827e2 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -42,7 +42,7 @@ const useAuth = () => { }) const login = async (data: AccessToken) => { - const response = await LoginService.loginAccessToken({ + await LoginService.loginAccessToken({ formData: data, }) localStorage.setItem("is_authenticated", "true") From 1014a230afd161a0aa73a7be5077b22385015528 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Sat, 26 Apr 2025 13:00:54 +0200 Subject: [PATCH 09/13] Fix TS errors --- frontend/src/client/core/request.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/client/core/request.ts b/frontend/src/client/core/request.ts index daf2403b65c..ee913cd30f9 100644 --- a/frontend/src/client/core/request.ts +++ b/frontend/src/client/core/request.ts @@ -132,10 +132,14 @@ export const getHeaders = async ( options: ApiRequestOptions, ): Promise> => { const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options as ApiRequestOptions, config.TOKEN), - resolve(options as ApiRequestOptions, config.USERNAME), - resolve(options as ApiRequestOptions, config.PASSWORD), - resolve>(options as ApiRequestOptions>, config.HEADERS), + // @ts-ignore + resolve(options, config.TOKEN), + // @ts-ignore + resolve(options, config.USERNAME), + // @ts-ignore + resolve(options, config.PASSWORD), + // @ts-ignore + resolve(options, config.HEADERS), ]) const headers = Object.entries({ From fd789a60981a6456553e6a3e932ae4c58f40b129 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Wed, 30 Apr 2025 11:59:20 +0200 Subject: [PATCH 10/13] cleanup --- backend/app/api/routes/items.py | 1 - backend/app/api/routes/login.py | 4 +--- backend/app/core/security.py | 4 ++-- frontend/src/client/core/request.ts | 11 ----------- frontend/src/client/sdk.gen.ts | 1 - 5 files changed, 3 insertions(+), 18 deletions(-) diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index feed3041a9e..177dc1e4760 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -100,7 +100,6 @@ def delete_item( Delete an item. """ item = session.get(Item, id) - print(current_user) if not item: raise HTTPException(status_code=404, detail="Item not found") if not current_user.is_superuser and (item.owner_id != current_user.id): diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 179541c63f2..18906f0ea55 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -41,9 +41,7 @@ def login_access_token( elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - r = security.set_auth_cookie(user.id, access_token_expires) - print(r) - return r + return security.set_auth_cookie(user.id, access_token_expires) @router.post("/login/test-token", response_model=UserPublic) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 3ef49755401..10e51b5497f 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -28,8 +28,8 @@ def set_auth_cookie(subject: str | Any, expires_delta: timedelta) -> Response: key="http_only_auth_cookie", value=access_token, httponly=True, - max_age=3600, - expires=3600, + max_age=30, + expires=30, samesite="lax", secure=True, domain=None, diff --git a/frontend/src/client/core/request.ts b/frontend/src/client/core/request.ts index ee913cd30f9..d79dfebe6b0 100644 --- a/frontend/src/client/core/request.ts +++ b/frontend/src/client/core/request.ts @@ -205,14 +205,12 @@ export const sendRequest = async ( ): Promise> => { const controller = new AbortController() - // Properly handle form data for URL-encoded submissions let data = body; // If we have formData but it's not a FormData instance, // and Content-Type is application/x-www-form-urlencoded if (options.formData && !isFormData(options.formData) && headers["Content-Type"] === "application/x-www-form-urlencoded") { - // Use URLSearchParams or axios's built-in handling for url-encoded data const params = new URLSearchParams(); Object.entries(options.formData).forEach(([key, value]) => { if (value !== undefined && value !== null) { @@ -232,14 +230,6 @@ export const sendRequest = async ( url, withCredentials: options.withCredentials ?? config.WITH_CREDENTIALS, } - - console.log("Request config:", JSON.stringify({ - url: requestConfig.url, - method: requestConfig.method, - headers: requestConfig.headers, - withCredentials: requestConfig.withCredentials - })); - onCancel(() => controller.abort()) for (const fn of config.interceptors.request._fns) { @@ -402,7 +392,6 @@ export const request = ( statusText: response.statusText, body: responseHeader ?? transformedBody, } - console.log(result) catchErrorCodes(options, result) resolve(result.body) diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 722d76e20eb..5d952a0a72c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -232,7 +232,6 @@ export class LoginService { * @throws ApiError */ public static testToken(): CancelablePromise { - console.log("test token") return __request(OpenAPI, { method: "POST", url: "/api/v1/login/test-token", From 71affa949a973af230e906127fcf01bd25bd5e18 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Wed, 30 Apr 2025 13:25:55 +0200 Subject: [PATCH 11/13] Fix lint errors --- backend/.idea/.gitignore | 3 ++ backend/.idea/backend.iml | 14 +++++++++ .../inspectionProfiles/profiles_settings.xml | 6 ++++ backend/.idea/misc.xml | 4 +++ backend/.idea/modules.xml | 8 +++++ backend/.idea/vcs.xml | 6 ++++ backend/app/core/security.py | 5 ++- backend/app/tests/utils/user.py | 1 + backend/app/tests/utils/utils.py | 31 +++++++++++++------ 9 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 backend/.idea/.gitignore create mode 100644 backend/.idea/backend.iml create mode 100644 backend/.idea/inspectionProfiles/profiles_settings.xml create mode 100644 backend/.idea/misc.xml create mode 100644 backend/.idea/modules.xml create mode 100644 backend/.idea/vcs.xml diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore new file mode 100644 index 00000000000..26d33521af1 --- /dev/null +++ b/backend/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/backend/.idea/backend.iml b/backend/.idea/backend.iml new file mode 100644 index 00000000000..5c1f14c14cc --- /dev/null +++ b/backend/.idea/backend.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/backend/.idea/inspectionProfiles/profiles_settings.xml b/backend/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 00000000000..cc5462daf86 --- /dev/null +++ b/backend/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + diff --git a/backend/.idea/misc.xml b/backend/.idea/misc.xml new file mode 100644 index 00000000000..c4bf9ff881d --- /dev/null +++ b/backend/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + diff --git a/backend/.idea/modules.xml b/backend/.idea/modules.xml new file mode 100644 index 00000000000..f11a6b0441f --- /dev/null +++ b/backend/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/backend/.idea/vcs.xml b/backend/.idea/vcs.xml new file mode 100644 index 00000000000..54e4b961ee0 --- /dev/null +++ b/backend/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 10e51b5497f..037e4fdf47a 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -2,7 +2,6 @@ from typing import Any import jwt -from fastapi import Response from fastapi.responses import JSONResponse from passlib.context import CryptContext @@ -20,7 +19,7 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: return encoded_jwt -def set_auth_cookie(subject: str | Any, expires_delta: timedelta) -> Response: +def set_auth_cookie(subject: str | Any, expires_delta: timedelta) -> JSONResponse: access_token = create_access_token(subject, expires_delta) response = JSONResponse(content={"message": "Login successful"}) # Note: The secure flag on cookies ensures they're only sent over encrypted HTTPS connections. For local development (HTTP) set it to False @@ -37,7 +36,7 @@ def set_auth_cookie(subject: str | Any, expires_delta: timedelta) -> Response: return response -def delete_auth_cookie() -> Response: +def delete_auth_cookie() -> JSONResponse: response = JSONResponse(content={"message": "Logout successful"}) response.delete_cookie( diff --git a/backend/app/tests/utils/user.py b/backend/app/tests/utils/user.py index 3cb81b26722..39150e401dd 100644 --- a/backend/app/tests/utils/user.py +++ b/backend/app/tests/utils/user.py @@ -13,6 +13,7 @@ def user_authentication_headers( data = {"username": email, "password": password} r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data) + print(f"user_authentication_headers: {r}") return extract_cookies(r) diff --git a/backend/app/tests/utils/utils.py b/backend/app/tests/utils/utils.py index 6444256bb71..99a3c9d0e15 100644 --- a/backend/app/tests/utils/utils.py +++ b/backend/app/tests/utils/utils.py @@ -1,6 +1,8 @@ import random import string +import httpx +from fastapi import Response from fastapi.responses import JSONResponse from fastapi.testclient import TestClient @@ -24,13 +26,22 @@ def get_superuser_auth_cookies(client: TestClient) -> dict[str, str]: return extract_cookies(r) -def extract_cookies(response: JSONResponse) -> dict[str, str]: - cookie_header = response.headers.get("Set-Cookie") - - cookie_value = None - if cookie_header and "http_only_auth_cookie=" in cookie_header: - cookie_value = cookie_header.split("http_only_auth_cookie=")[1].split(";")[0] - - assert cookie_value, "Cookie value not found" - - return {"http_only_auth_cookie": cookie_value} +def extract_cookies( + response: JSONResponse | httpx._models.Response | Response, +) -> dict[str, str]: + if isinstance(response, httpx._models.Response): + # Handle httpx Response + cookie_value = response.cookies.get("http_only_auth_cookie") + if cookie_value: + return {"http_only_auth_cookie": cookie_value} + else: + # Handle Starlette Response + cookie_header = response.headers.get("Set-Cookie") + if cookie_header and "http_only_auth_cookie=" in cookie_header: + cookie_value = cookie_header.split("http_only_auth_cookie=")[1].split(";")[ + 0 + ] + if cookie_value: + return {"http_only_auth_cookie": cookie_value} + + raise AssertionError("Cookie value not found") From 5549d13cc2bdd4d98f55b357e25fabb11c80b696 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Fri, 2 May 2025 11:01:18 +0200 Subject: [PATCH 12/13] Set cookie expiration to 1 hour --- backend/app/core/security.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 037e4fdf47a..d532564d0c6 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -27,8 +27,8 @@ def set_auth_cookie(subject: str | Any, expires_delta: timedelta) -> JSONRespons key="http_only_auth_cookie", value=access_token, httponly=True, - max_age=30, - expires=30, + max_age=3600, + expires=3600, samesite="lax", secure=True, domain=None, From cd16f1f588e5704cd3c17eb5a338768901f8e008 Mon Sep 17 00:00:00 2001 From: Zsofia Sinko Date: Fri, 2 May 2025 12:05:28 +0200 Subject: [PATCH 13/13] Include credentials in API requests to enable cookie-based authentication --- frontend/tests/utils/privateApi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/tests/utils/privateApi.ts b/frontend/tests/utils/privateApi.ts index b6fa0afe676..82c479ef323 100644 --- a/frontend/tests/utils/privateApi.ts +++ b/frontend/tests/utils/privateApi.ts @@ -18,5 +18,6 @@ export const createUser = async ({ is_verified: true, full_name: "Test User", }, + credentials: "include", }) }