From d475b3876a426b0493b00602d118a1ef87a44e01 Mon Sep 17 00:00:00 2001 From: CartSnitch Engineer Bot Date: Wed, 1 Apr 2026 02:09:55 +0000 Subject: [PATCH 1/3] fix(api): hash session token before DB lookup to match Better-Auth storage Better-Auth v1.5.6+ stores session tokens as SHA-256 hashes in the sessions table. The raw token from the cookie was being queried directly, causing all authenticated /api/v1/* requests to return 401. Fixes CAR-313. Co-Authored-By: Paperclip --- api/src/cartsnitch_api/auth/dependencies.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index 93f8eb8..54bac74 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/src/cartsnitch_api/auth/dependencies.py @@ -5,6 +5,7 @@ Sessions are verified by querying the shared sessions table directly. """ from datetime import UTC, datetime +from hashlib import sha256 from uuid import UUID from fastapi import Cookie, Depends, Header, HTTPException, Request, status @@ -27,10 +28,13 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID: """Validate a Better-Auth session token against the sessions table. Returns the user_id (as UUID) if the session is valid and not expired. + Better-Auth v1.5.6+ stores tokens as SHA-256 hashes, so we hash the + incoming raw token before querying. """ + hashed_token = sha256(token.encode("utf-8")).hexdigest() result = await db.execute( text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), - {"token": token}, + {"token": hashed_token}, ) row = result.first() From 02e5bee390c17e772e8276d85f2e05993f10fc75 Mon Sep 17 00:00:00 2001 From: CartSnitch Engineer Bot Date: Wed, 1 Apr 2026 02:10:12 +0000 Subject: [PATCH 2/3] fix(frontend): align API route paths with backend (alerts, price-history) Change frontend to call /alerts (was /price-alerts) and /products/{id}/prices (was /products/{id}/price-history) to match the backend router mounts. Co-Authored-By: Paperclip --- src/hooks/useApi.ts | 4 ++-- src/lib/api.ts | 4 ++-- src/test/mocks/handlers.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index 235b5f6..cccd77d 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -35,7 +35,7 @@ export function useProduct(id: string) { export function usePriceHistory(productId: string) { return useQuery({ queryKey: ['priceHistory', productId], - queryFn: () => api.get(`/products/${productId}/price-history`), + queryFn: () => api.get(`/products/${productId}/prices`), enabled: !!productId, }) } @@ -50,6 +50,6 @@ export function useCoupons() { export function usePriceAlerts() { return useQuery({ queryKey: ['priceAlerts'], - queryFn: () => api.get('/price-alerts'), + queryFn: () => api.get('/alerts'), }) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 3907dde..1c2b0f8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -15,7 +15,7 @@ const mockRoutes: Record unknown> = { '/purchases': () => mockPurchases, '/products': () => mockProducts, '/coupons': () => mockCoupons, - '/price-alerts': () => mockAlerts, + '/alerts': () => mockAlerts, } function matchMockRoute(path: string): T | null { @@ -30,7 +30,7 @@ function matchMockRoute(path: string): T | null { } // /products/:id/price-history - const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/) + const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/) if (priceHistoryMatch) { return getMockPriceHistory(priceHistoryMatch[1]) as T } diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index ddfa9d5..4282004 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -61,5 +61,5 @@ export const handlers = [ http.get('/api/v1/products', () => HttpResponse.json(mockProducts)), http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])), http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)), - http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)), + http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)), ] From ac4cba2b0ddb296beedba1c7154b596720fdb860 Mon Sep 17 00:00:00 2001 From: "cartsnitch-engineer[bot]" <269717931+cartsnitch-engineer[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:02:49 +0000 Subject: [PATCH 3/3] fix(api): read __Secure- prefixed session cookie for HTTPS environments Better-Auth automatically prefixes cookie names with __Secure- when serving over HTTPS. The API gateway now tries __Secure-better-auth.session_token first (HTTPS/deployed), falling back to better-auth.session_token (HTTP/local dev). Fixes CAR-321. Co-Authored-By: Paperclip --- api/src/cartsnitch_api/auth/dependencies.py | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index 54bac74..1c68381 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/src/cartsnitch_api/auth/dependencies.py @@ -5,7 +5,6 @@ Sessions are verified by querying the shared sessions table directly. """ from datetime import UTC, datetime -from hashlib import sha256 from uuid import UUID from fastapi import Cookie, Depends, Header, HTTPException, Request, status @@ -20,21 +19,22 @@ from cartsnitch_api.database import get_db # but we support Bearer tokens for service-to-service or mobile clients. bearer_scheme = HTTPBearer(auto_error=False) -# Better-Auth session cookie name -SESSION_COOKIE_NAME = "better-auth.session_token" +# Better-Auth session cookie names. +# Over HTTPS Better-Auth adds the __Secure- prefix automatically. +SESSION_COOKIE_NAMES = [ + "__Secure-better-auth.session_token", # HTTPS (deployed) + "better-auth.session_token", # HTTP (local dev) +] async def _validate_session_token(token: str, db: AsyncSession) -> UUID: """Validate a Better-Auth session token against the sessions table. Returns the user_id (as UUID) if the session is valid and not expired. - Better-Auth v1.5.6+ stores tokens as SHA-256 hashes, so we hash the - incoming raw token before querying. """ - hashed_token = sha256(token.encode("utf-8")).hexdigest() result = await db.execute( text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), - {"token": hashed_token}, + {"token": token}, ) row = result.first() @@ -71,8 +71,12 @@ async def get_current_user( """ token: str | None = None - # 1. Check session cookie - cookie_token = request.cookies.get(SESSION_COOKIE_NAME) + # 1. Check session cookie (try both names for HTTP/HTTPS compatibility) + cookie_token = None + for name in SESSION_COOKIE_NAMES: + cookie_token = request.cookies.get(name) + if cookie_token: + break if cookie_token: token = cookie_token