diff --git a/Frontend/src/store/authStore.js b/Frontend/src/store/authStore.js index 0b81b163..4d7a46bb 100644 --- a/Frontend/src/store/authStore.js +++ b/Frontend/src/store/authStore.js @@ -6,6 +6,18 @@ import useTicketStore from './ticketStore'; const BACKEND_URL = API_CONFIG.BACKEND_URL; +/** + * Normalize email for Supabase — encode `+` to %2B to prevent + * misinterpretation as space during URL/form-encoding at any layer. + * Emails with + (sub-addressing, e.g. user+test@example.com) are + * valid per RFC 5321 and must be passed literally. + */ +const normalizeEmail = (email) => { + if (typeof email !== 'string') return email; + // Only encode the + sign — everything else stays as-is for JSON transport + return email.replace(/\+/g, '%2B'); +}; + const verifyServerCookieSession = async () => { try { const controller = new AbortController(); @@ -161,12 +173,14 @@ const useAuthStore = create( login: async (email, password) => { set({ loading: true }); + // Backend mirror uses URL-encoded form, so encode + as %2B for that transport only + const safeEmail = normalizeEmail(email); console.log("Attempting login for:", email); try { - await mirrorBackendAuth('/auth/login', { email, password }); + await mirrorBackendAuth('/auth/login', { email: safeEmail, password }); const { data, error } = await supabase.auth.signInWithPassword({ - email, + email: email, // Supabase JS client uses JSON — pass raw email password, }); @@ -217,10 +231,12 @@ const useAuthStore = create( signInWithMagicLink: async (email) => { set({ loading: true }); + // safeEmail only needed for backend mirror; Supabase JSON client gets raw email + const safeEmail = normalizeEmail(email); console.log("Attempting magic link / OTP login for:", email); try { const { error } = await supabase.auth.signInWithOtp({ - email, + email: email, // raw email for Supabase JSON transport options: { shouldCreateUser: false, // Only existing users } @@ -259,10 +275,12 @@ const useAuthStore = create( verifyOtpAndLogin: async (email, token, type = 'magiclink') => { set({ loading: true }); + // safeEmail only needed for backend mirror; Supabase JSON client gets raw email + const safeEmail = normalizeEmail(email); console.log("Attempting OTP verification for:", email); try { const { data, error } = await supabase.auth.verifyOtp({ - email, + email: email, // raw email for Supabase JSON transport token, type, }); @@ -291,11 +309,13 @@ const useAuthStore = create( signup: async (email, password, fullName, role = 'user', company = '', extraMetadata = {}, emailRedirectTo = undefined) => { set({ loading: true }); + // Backend mirror uses URL-encoded form; Supabase JS client uses JSON (raw email) + const safeEmail = normalizeEmail(email); console.log("Starting signup for:", email); try { await mirrorBackendAuth('/auth/signup', { - email, + email: safeEmail, password, full_name: fullName, role, @@ -305,7 +325,7 @@ const useAuthStore = create( // 1. Auth Signup with Metadata console.log("Step 1: Auth.signUp..."); const { data, error } = await supabase.auth.signUp({ - email, + email: email, // raw email for Supabase JSON transport password, options: { data: { @@ -425,4 +445,3 @@ const useAuthStore = create( ); export default useAuthStore; - diff --git a/backend/auth_cookie.py b/backend/auth_cookie.py index f1719008..92ca32a1 100644 --- a/backend/auth_cookie.py +++ b/backend/auth_cookie.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import urllib.parse from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, Response, status @@ -112,10 +113,19 @@ class SignupBody(BaseModel): @router.post("/login") async def auth_login(body: LoginBody, response: Response): + # Decode any URL-encoded characters (e.g. %2B → +) in the email. + # This prevents the `+` character in email sub-addressing (user+tag@...) + # from being corrupted by intermediate form/URL encoding layers. + raw_email = str(body.email) + try: + safe_email = urllib.parse.unquote(raw_email) + except Exception: + safe_email = raw_email + try: client = _anon_supabase() result = client.auth.sign_in_with_password( - {"email": str(body.email), "password": body.password} + {"email": safe_email, "password": body.password} ) except Exception as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc @@ -132,6 +142,13 @@ async def auth_login(body: LoginBody, response: Response): @router.post("/signup") async def auth_signup(body: SignupBody, response: Response): + # Decode URL-encoded characters in email (defense in depth) + raw_email = str(body.email) + try: + safe_email = urllib.parse.unquote(raw_email) + except Exception: + safe_email = raw_email + metadata: dict[str, str] = {} if body.full_name: metadata["full_name"] = body.full_name @@ -144,7 +161,7 @@ async def auth_signup(body: SignupBody, response: Response): client = _anon_supabase() result = client.auth.sign_up( { - "email": str(body.email), + "email": safe_email, "password": body.password, "options": {"data": metadata} if metadata else {}, } @@ -152,17 +169,13 @@ async def auth_signup(body: SignupBody, response: Response): except Exception as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - session = getattr(result, "session", None) - user = getattr(result, "user", None) - if session: - _set_session_cookies(response, session) - user_payload = user.model_dump() if user and hasattr(user, "model_dump") else None - return {"user": user_payload, "message": "Signup complete"} + response.status_code = status.HTTP_201_CREATED + return {"message": "Signup initiated, check your email for verification."} @router.post("/logout") async def auth_logout(request: Request, response: Response): - # Invalidate the session server-side before clearing cookies + # Revoke Supabase session server-side before clearing cookies token = extract_token(request) if token: try: @@ -171,7 +184,7 @@ async def auth_logout(request: Request, response: Response): except Exception: pass # Still clear cookies even if server-side invalidation fails _clear_session_cookies(response) - return {"ok": True} + return {"message": "Logged out"} @router.get("/me")