From 183f6f5f8c2bcbae02dd332e5e34eb1a2d08e932 Mon Sep 17 00:00:00 2001 From: CartSnitch Engineer Bot Date: Wed, 1 Apr 2026 11:09:29 +0000 Subject: [PATCH 1/2] fix(api): parse signed session cookie instead of SHA-256 hashing Better-Auth v1.5.6 stores raw tokens in sessions.token, not SHA-256 hashes. The session cookie is signed (rawToken.hmacSignature), so strip the HMAC signature suffix before querying the DB. Fixes 401 errors on all data endpoints caused by the incorrect hash. Co-Authored-By: Paperclip --- src/cartsnitch_api/auth/dependencies.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cartsnitch_api/auth/dependencies.py b/src/cartsnitch_api/auth/dependencies.py index ac9e5fd..451ae70 100644 --- a/src/cartsnitch_api/auth/dependencies.py +++ b/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 @@ -32,13 +31,15 @@ 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. + Better-Auth v1.5.6 stores raw tokens in the DB. The session cookie + is signed: ``rawToken.base64HMACSignature``. Strip the signature + before querying. """ - hashed_token = sha256(token.encode("utf-8")).hexdigest() + # Signed cookie format: rawToken.hmacSignature — split and use only the token part + raw_token = token.split(".")[0] if "." in token else token result = await db.execute( text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), - {"token": hashed_token}, + {"token": raw_token}, ) row = result.first() From c46e52419388a86a7ffd516ac83c08edbe8d9e1f Mon Sep 17 00:00:00 2001 From: "cartsnitch-engineer[bot]" <269717931+cartsnitch-engineer[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:15:58 +0000 Subject: [PATCH 2/2] fix(api): replace UUID type with str for Better-Auth nanoid user IDs (#98) Better-Auth uses nanoid strings for user IDs, not UUIDs. Changed all user_id parameter/return types in the API layer from UUID to str, removed the obsolete UUID import where unused, and updated the _validate_session_token return type accordingly. Co-authored-by: CartSnitch Engineer Bot Co-authored-by: Paperclip --- src/cartsnitch_api/auth/dependencies.py | 9 ++++----- src/cartsnitch_api/auth/jwt.py | 9 ++++----- src/cartsnitch_api/auth/routes.py | 8 +++----- src/cartsnitch_api/routes/alerts.py | 8 +++----- src/cartsnitch_api/routes/coupons.py | 4 ++-- src/cartsnitch_api/routes/prices.py | 6 +++--- src/cartsnitch_api/routes/products.py | 6 +++--- src/cartsnitch_api/routes/purchases.py | 6 +++--- src/cartsnitch_api/routes/scraping.py | 6 ++---- src/cartsnitch_api/routes/shopping.py | 6 ++---- src/cartsnitch_api/routes/stores.py | 8 +++----- src/cartsnitch_api/services/alerts.py | 8 +++----- src/cartsnitch_api/services/auth.py | 8 +++----- src/cartsnitch_api/services/coupons.py | 2 +- src/cartsnitch_api/services/purchases.py | 6 +++--- src/cartsnitch_api/services/stores.py | 7 +++---- 16 files changed, 45 insertions(+), 62 deletions(-) diff --git a/src/cartsnitch_api/auth/dependencies.py b/src/cartsnitch_api/auth/dependencies.py index 451ae70..a3735eb 100644 --- a/src/cartsnitch_api/auth/dependencies.py +++ b/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 uuid import UUID from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -27,10 +26,10 @@ SESSION_COOKIE_NAMES = [ ] -async def _validate_session_token(token: str, db: AsyncSession) -> UUID: +async def _validate_session_token(token: str, db: AsyncSession) -> str: """Validate a Better-Auth session token against the sessions table. - Returns the user_id (as UUID) if the session is valid and not expired. + Returns the user_id (as str) if the session is valid and not expired. Better-Auth v1.5.6 stores raw tokens in the DB. The session cookie is signed: ``rawToken.base64HMACSignature``. Strip the signature before querying. @@ -60,14 +59,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID: detail="Session expired", ) - return UUID(str(user_id)) + return str(user_id) async def get_current_user( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), db: AsyncSession = Depends(get_db), -) -> UUID: +) -> str: """Extract and validate the session token from cookie or Authorization header. Checks in order: diff --git a/src/cartsnitch_api/auth/jwt.py b/src/cartsnitch_api/auth/jwt.py index 100c77b..4e127bc 100644 --- a/src/cartsnitch_api/auth/jwt.py +++ b/src/cartsnitch_api/auth/jwt.py @@ -2,22 +2,21 @@ from datetime import UTC, datetime, timedelta from typing import Any, cast -from uuid import UUID from jose import JWTError, jwt from cartsnitch_api.config import settings -def create_access_token(user_id: UUID) -> str: +def create_access_token(user_id: str) -> str: expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes) - payload = {"sub": str(user_id), "exp": expire, "type": "access"} + payload = {"sub": user_id, "exp": expire, "type": "access"} return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)) -def create_refresh_token(user_id: UUID) -> str: +def create_refresh_token(user_id: str) -> str: expire = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days) - payload = {"sub": str(user_id), "exp": expire, "type": "refresh"} + payload = {"sub": user_id, "exp": expire, "type": "refresh"} return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)) diff --git a/src/cartsnitch_api/auth/routes.py b/src/cartsnitch_api/auth/routes.py index 81cae2f..2c547a4 100644 --- a/src/cartsnitch_api/auth/routes.py +++ b/src/cartsnitch_api/auth/routes.py @@ -5,8 +5,6 @@ the Better-Auth service (auth/). This router provides user profile endpoints that query our own user data from the shared database. """ -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -23,7 +21,7 @@ router = APIRouter(prefix="/auth", tags=["auth"]) @router.get("/me", response_model=UserResponse) async def get_me( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AuthService(db) @@ -38,7 +36,7 @@ async def get_me( @router.patch("/me", response_model=UserResponse) async def update_me( body: UpdateUserRequest, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AuthService(db) @@ -54,7 +52,7 @@ async def update_me( @router.delete("/me", status_code=status.HTTP_204_NO_CONTENT) async def delete_me( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AuthService(db) diff --git a/src/cartsnitch_api/routes/alerts.py b/src/cartsnitch_api/routes/alerts.py index 45ab33f..9b3fe8f 100644 --- a/src/cartsnitch_api/routes/alerts.py +++ b/src/cartsnitch_api/routes/alerts.py @@ -1,7 +1,5 @@ """Alert routes: list alerts, manage settings.""" -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +13,7 @@ router = APIRouter(prefix="/alerts", tags=["alerts"]) @router.get("", response_model=list[AlertResponse]) async def list_alerts( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AlertService(db) @@ -24,7 +22,7 @@ async def list_alerts( @router.get("/settings", response_model=AlertSettingsResponse) async def get_alert_settings( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AlertService(db) @@ -34,7 +32,7 @@ async def get_alert_settings( @router.put("/settings") async def update_alert_settings( body: AlertSettingsRequest, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): raise HTTPException( diff --git a/src/cartsnitch_api/routes/coupons.py b/src/cartsnitch_api/routes/coupons.py index d33d98a..9e43fbc 100644 --- a/src/cartsnitch_api/routes/coupons.py +++ b/src/cartsnitch_api/routes/coupons.py @@ -16,7 +16,7 @@ router = APIRouter(prefix="/coupons", tags=["coupons"]) @router.get("", response_model=list[CouponResponse]) async def list_coupons( store_id: UUID | None = Query(None), - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = CouponService(db) @@ -25,7 +25,7 @@ async def list_coupons( @router.get("/relevant", response_model=list[CouponResponse]) async def relevant_coupons( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = CouponService(db) diff --git a/src/cartsnitch_api/routes/prices.py b/src/cartsnitch_api/routes/prices.py index 487dd92..c39a1ce 100644 --- a/src/cartsnitch_api/routes/prices.py +++ b/src/cartsnitch_api/routes/prices.py @@ -20,7 +20,7 @@ router = APIRouter(prefix="/prices", tags=["prices"]) @router.get("/trends", response_model=list[PriceTrendResponse]) async def price_trends( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), category: str | None = Query(None), db: AsyncSession = Depends(get_db), ): @@ -30,7 +30,7 @@ async def price_trends( @router.get("/increases", response_model=list[PriceIncreaseResponse]) async def price_increases( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PriceService(db) @@ -40,7 +40,7 @@ async def price_increases( @router.get("/comparison", response_model=list[PriceComparisonResponse]) async def price_comparison( product_ids: Annotated[list[UUID], Query()], - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PriceService(db) diff --git a/src/cartsnitch_api/routes/products.py b/src/cartsnitch_api/routes/products.py index 473cefe..84205e8 100644 --- a/src/cartsnitch_api/routes/products.py +++ b/src/cartsnitch_api/routes/products.py @@ -15,7 +15,7 @@ router = APIRouter(prefix="/products", tags=["products"]) @router.get("", response_model=list[ProductResponse]) async def list_products( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), q: str | None = Query(None), category: str | None = Query(None), page: int = Query(1, ge=1), @@ -29,7 +29,7 @@ async def list_products( @router.get("/{product_id}", response_model=ProductDetailResponse) async def get_product( product_id: UUID, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = ProductService(db) @@ -44,7 +44,7 @@ async def get_product( @router.get("/{product_id}/prices", response_model=PriceTrendResponse) async def get_product_prices( product_id: UUID, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = ProductService(db) diff --git a/src/cartsnitch_api/routes/purchases.py b/src/cartsnitch_api/routes/purchases.py index eba86ac..a337c8e 100644 --- a/src/cartsnitch_api/routes/purchases.py +++ b/src/cartsnitch_api/routes/purchases.py @@ -15,7 +15,7 @@ router = APIRouter(prefix="/purchases", tags=["purchases"]) @router.get("", response_model=list[PurchaseResponse]) async def list_purchases( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), store_id: UUID | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), @@ -27,7 +27,7 @@ async def list_purchases( @router.get("/stats", response_model=PurchaseStatsResponse) async def purchase_stats( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PurchaseService(db) @@ -37,7 +37,7 @@ async def purchase_stats( @router.get("/{purchase_id}", response_model=PurchaseDetailResponse) async def get_purchase( purchase_id: UUID, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PurchaseService(db) diff --git a/src/cartsnitch_api/routes/scraping.py b/src/cartsnitch_api/routes/scraping.py index d8bbd5f..2804212 100644 --- a/src/cartsnitch_api/routes/scraping.py +++ b/src/cartsnitch_api/routes/scraping.py @@ -1,7 +1,5 @@ """Scraping routes: trigger sync, check status (proxy to ReceiptWitness).""" -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from httpx import HTTPStatusError, RequestError @@ -13,7 +11,7 @@ router = APIRouter(prefix="/scraping", tags=["scraping"]) @router.post("/{store_slug}/sync", response_model=SyncTriggerResponse) -async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user)): +async def trigger_sync(store_slug: str, user_id: str = Depends(get_current_user)): client = ReceiptWitnessClient() try: result = await client.trigger_sync(str(user_id), store_slug) @@ -31,7 +29,7 @@ async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user @router.get("/status", response_model=list[SyncStatusResponse]) -async def sync_status(user_id: UUID = Depends(get_current_user)): +async def sync_status(user_id: str = Depends(get_current_user)): client = ReceiptWitnessClient() try: return await client.get_sync_status(str(user_id)) diff --git a/src/cartsnitch_api/routes/shopping.py b/src/cartsnitch_api/routes/shopping.py index c64d5fd..f7c3d0e 100644 --- a/src/cartsnitch_api/routes/shopping.py +++ b/src/cartsnitch_api/routes/shopping.py @@ -1,7 +1,5 @@ """Shopping routes: optimize list, saved lists.""" -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from httpx import HTTPStatusError, RequestError @@ -13,7 +11,7 @@ router = APIRouter(prefix="/shopping", tags=["shopping"]) @router.post("/optimize", response_model=OptimizeResponse) -async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_current_user)): +async def optimize_shopping(body: OptimizeRequest, user_id: str = Depends(get_current_user)): client = ClipArtistClient() try: result = await client.optimize( @@ -37,7 +35,7 @@ async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_c @router.get("/lists", response_model=list[ShoppingListResponse]) -async def list_shopping_lists(user_id: UUID = Depends(get_current_user)): +async def list_shopping_lists(user_id: str = Depends(get_current_user)): client = ClipArtistClient() try: return await client.get_shopping_lists(str(user_id)) diff --git a/src/cartsnitch_api/routes/stores.py b/src/cartsnitch_api/routes/stores.py index 1ab7947..1525933 100644 --- a/src/cartsnitch_api/routes/stores.py +++ b/src/cartsnitch_api/routes/stores.py @@ -1,7 +1,5 @@ """Store routes: list stores, manage user store connections.""" -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -21,7 +19,7 @@ async def list_stores(db: AsyncSession = Depends(get_db)): @router.get("/me/stores", response_model=list[StoreAccountResponse]) async def list_user_stores( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = StoreService(db) @@ -36,7 +34,7 @@ async def list_user_stores( async def connect_store( store_slug: str, body: ConnectStoreRequest, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = StoreService(db) @@ -51,7 +49,7 @@ async def connect_store( @router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT) async def disconnect_store( store_slug: str, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = StoreService(db) diff --git a/src/cartsnitch_api/services/alerts.py b/src/cartsnitch_api/services/alerts.py index fc3ddd4..cc03d60 100644 --- a/src/cartsnitch_api/services/alerts.py +++ b/src/cartsnitch_api/services/alerts.py @@ -4,8 +4,6 @@ Alerts are generated by StickerShock and ShrinkRay services and written to the D This service reads them for the API gateway. """ -from uuid import UUID - from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -15,7 +13,7 @@ class AlertService: def __init__(self, db: AsyncSession) -> None: self.db = db - async def list_alerts(self, user_id: UUID) -> list[dict]: + async def list_alerts(self, user_id: str) -> list[dict]: """List shrinkflation events for products the user has purchased.""" from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent @@ -57,7 +55,7 @@ class AlertService: for e in events ] - async def get_settings(self, user_id: UUID) -> dict: + async def get_settings(self, user_id: str) -> dict: # Alert settings would be stored in a user_settings table. # For now, return defaults since the table doesn't exist yet in common lib. return { @@ -66,7 +64,7 @@ class AlertService: "email_notifications": False, } - async def update_settings(self, user_id: UUID, **fields) -> dict: + async def update_settings(self, user_id: str, **fields) -> dict: # Would update user_settings table. Return merged defaults for now. current = await self.get_settings(user_id) for k, v in fields.items(): diff --git a/src/cartsnitch_api/services/auth.py b/src/cartsnitch_api/services/auth.py index 91724af..4894150 100644 --- a/src/cartsnitch_api/services/auth.py +++ b/src/cartsnitch_api/services/auth.py @@ -5,8 +5,6 @@ handled by the Better-Auth service (auth/). This service provides user lookup and profile update operations for the API gateway. """ -from uuid import UUID - from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +13,7 @@ class AuthService: def __init__(self, db: AsyncSession) -> None: self.db = db - async def get_user(self, user_id: UUID) -> dict: + async def get_user(self, user_id: str) -> dict: from cartsnitch_api.models import User result = await self.db.execute(select(User).where(User.id == user_id)) @@ -30,7 +28,7 @@ class AuthService: "created_at": user.created_at, } - async def update_user(self, user_id: UUID, **fields) -> dict: + async def update_user(self, user_id: str, **fields) -> dict: from cartsnitch_api.models import User result = await self.db.execute(select(User).where(User.id == user_id)) @@ -58,7 +56,7 @@ class AuthService: "created_at": user.created_at, } - async def delete_user(self, user_id: UUID) -> None: + async def delete_user(self, user_id: str) -> None: from cartsnitch_api.models import User result = await self.db.execute(select(User).where(User.id == user_id)) diff --git a/src/cartsnitch_api/services/coupons.py b/src/cartsnitch_api/services/coupons.py index 9b1543e..a5b8a2c 100644 --- a/src/cartsnitch_api/services/coupons.py +++ b/src/cartsnitch_api/services/coupons.py @@ -29,7 +29,7 @@ class CouponService: coupons = result.scalars().all() return [self._to_dict(c) for c in coupons] - async def relevant_coupons(self, user_id: UUID) -> list[dict]: + async def relevant_coupons(self, user_id: str) -> list[dict]: """Coupons for products the user has purchased.""" from cartsnitch_api.models import Coupon, PurchaseItem diff --git a/src/cartsnitch_api/services/purchases.py b/src/cartsnitch_api/services/purchases.py index 41776f4..10ca0a4 100644 --- a/src/cartsnitch_api/services/purchases.py +++ b/src/cartsnitch_api/services/purchases.py @@ -13,7 +13,7 @@ class PurchaseService: async def list_purchases( self, - user_id: UUID, + user_id: str, store_id: UUID | None = None, page: int = 1, page_size: int = 20, @@ -56,7 +56,7 @@ class PurchaseService: for p, item_count, store_name in result.all() ] - async def get_purchase(self, purchase_id: UUID, user_id: UUID) -> dict: + async def get_purchase(self, purchase_id: UUID, user_id: str) -> dict: from cartsnitch_api.models import Purchase result = await self.db.execute( @@ -88,7 +88,7 @@ class PurchaseService: ], } - async def get_stats(self, user_id: UUID) -> dict: + async def get_stats(self, user_id: str) -> dict: from cartsnitch_api.models import Purchase result = await self.db.execute( diff --git a/src/cartsnitch_api/services/stores.py b/src/cartsnitch_api/services/stores.py index 610f47e..c7d43ec 100644 --- a/src/cartsnitch_api/services/stores.py +++ b/src/cartsnitch_api/services/stores.py @@ -1,7 +1,6 @@ """Store service — list stores, manage user store account connections.""" import json -from uuid import UUID from cryptography.fernet import Fernet from sqlalchemy import select @@ -35,7 +34,7 @@ class StoreService: for s in stores ] - async def list_user_stores(self, user_id: UUID) -> list[dict]: + async def list_user_stores(self, user_id: str) -> list[dict]: from cartsnitch_api.models import UserStoreAccount result = await self.db.execute( @@ -60,7 +59,7 @@ class StoreService: for a in accounts ] - async def connect_store(self, user_id: UUID, store_slug: str, credentials: dict | None) -> dict: + async def connect_store(self, user_id: str, store_slug: str, credentials: dict | None) -> dict: from cartsnitch_api.models import Store, UserStoreAccount result = await self.db.execute(select(Store).where(Store.slug == store_slug)) @@ -107,7 +106,7 @@ class StoreService: "sync_status": "active", } - async def disconnect_store(self, user_id: UUID, store_slug: str) -> None: + async def disconnect_store(self, user_id: str, store_slug: str) -> None: from cartsnitch_api.models import Store, UserStoreAccount result = await self.db.execute(select(Store).where(Store.slug == store_slug))