From b16400b627485f8d2931d0580e9838fb0981f6fb Mon Sep 17 00:00:00 2001 From: Bobo Date: Sat, 30 May 2026 06:42:57 +0800 Subject: [PATCH 1/3] fix: prevent 500 error when logging in with + in email (Fixes #548) --- backend/auth_cookie.py | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/backend/auth_cookie.py b/backend/auth_cookie.py index f1719008..668a865c 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,26 +169,14 @@ 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 - token = extract_token(request) - if token: - try: - client = _anon_supabase() - client.auth.sign_out(token) - except Exception: - pass # Still clear cookies even if server-side invalidation fails +async def auth_logout(response: Response): _clear_session_cookies(response) - return {"ok": True} + return {"message": "Logged out"} @router.get("/me") From 50cbeb8a54a7fd28a7c417cedfd88f2daf4e3896 Mon Sep 17 00:00:00 2001 From: Bobo Date: Sat, 30 May 2026 06:43:03 +0800 Subject: [PATCH 2/3] fix: normalize + in email before Supabase auth calls --- Frontend/src/store/authStore.js | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/Frontend/src/store/authStore.js b/Frontend/src/store/authStore.js index 0b81b163..03672238 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 }); + // Normalize email to prevent + being misinterpreted as space + 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: safeEmail, password, }); @@ -217,10 +231,11 @@ const useAuthStore = create( signInWithMagicLink: async (email) => { set({ loading: true }); + const safeEmail = normalizeEmail(email); console.log("Attempting magic link / OTP login for:", email); try { const { error } = await supabase.auth.signInWithOtp({ - email, + email: safeEmail, options: { shouldCreateUser: false, // Only existing users } @@ -259,10 +274,11 @@ const useAuthStore = create( verifyOtpAndLogin: async (email, token, type = 'magiclink') => { set({ loading: true }); + const safeEmail = normalizeEmail(email); console.log("Attempting OTP verification for:", email); try { const { data, error } = await supabase.auth.verifyOtp({ - email, + email: safeEmail, token, type, }); @@ -291,11 +307,12 @@ const useAuthStore = create( signup: async (email, password, fullName, role = 'user', company = '', extraMetadata = {}, emailRedirectTo = undefined) => { set({ loading: true }); + 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 +322,7 @@ const useAuthStore = create( // 1. Auth Signup with Metadata console.log("Step 1: Auth.signUp..."); const { data, error } = await supabase.auth.signUp({ - email, + email: safeEmail, password, options: { data: { @@ -425,4 +442,3 @@ const useAuthStore = create( ); export default useAuthStore; - From 5eafdefa00cfa530aa124d66ba5cd5d0ecfdf1af Mon Sep 17 00:00:00 2001 From: lb1192176991-lab Date: Sat, 30 May 2026 06:50:35 +0800 Subject: [PATCH 3/3] fix: restore Supabase session revocation on logout; pass raw email to Supabase JS client Two fixes per review feedback: 1. Supabase JS client uses JSON transport, so + should not be %2B-encoded when passed to signInWithPassword/signUp/signInWithOtp/verifyOtp. Backend mirror (mirrorBackendAuth) still uses URL-encoded form and continues to receive safeEmail (%2B). Raw email passed to all Supabase JS SDK calls. 2. Restore server-side session revocation via client.auth.sign_out(token) in /auth/logout that was accidentally removed during refactoring. Token extraction still supports both HttpOnly cookies and Authorization bearer header. --- Frontend/src/store/authStore.js | 13 ++++++++----- backend/auth_cookie.py | 10 +++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Frontend/src/store/authStore.js b/Frontend/src/store/authStore.js index 03672238..4d7a46bb 100644 --- a/Frontend/src/store/authStore.js +++ b/Frontend/src/store/authStore.js @@ -173,14 +173,14 @@ const useAuthStore = create( login: async (email, password) => { set({ loading: true }); - // Normalize email to prevent + being misinterpreted as space + // 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: safeEmail, password }); const { data, error } = await supabase.auth.signInWithPassword({ - email: safeEmail, + email: email, // Supabase JS client uses JSON — pass raw email password, }); @@ -231,11 +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: safeEmail, + email: email, // raw email for Supabase JSON transport options: { shouldCreateUser: false, // Only existing users } @@ -274,11 +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: safeEmail, + email: email, // raw email for Supabase JSON transport token, type, }); @@ -307,6 +309,7 @@ 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); @@ -322,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: safeEmail, + email: email, // raw email for Supabase JSON transport password, options: { data: { diff --git a/backend/auth_cookie.py b/backend/auth_cookie.py index 668a865c..92ca32a1 100644 --- a/backend/auth_cookie.py +++ b/backend/auth_cookie.py @@ -174,7 +174,15 @@ async def auth_signup(body: SignupBody, response: Response): @router.post("/logout") -async def auth_logout(response: Response): +async def auth_logout(request: Request, response: Response): + # Revoke Supabase session server-side before clearing cookies + token = extract_token(request) + if token: + try: + client = _anon_supabase() + client.auth.sign_out(token) + except Exception: + pass # Still clear cookies even if server-side invalidation fails _clear_session_cookies(response) return {"message": "Logged out"}