Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions Frontend/src/store/authStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Comment on lines 182 to 184
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Send the literal email to Supabase auth

For addresses containing +, normalizeEmail turns user+tag@example.com into user%2Btag@example.com, and this value is passed directly to the Supabase JS client. These calls send JSON, so Supabase treats the percent sequence as part of the email rather than decoding it; after the backend mirror succeeds, signInWithPassword still looks up the wrong address and the login fails. The same safeEmail is also used for OTP and signup, so only the backend transport should receive any encoded value while Supabase auth should receive the original email string.

Useful? React with 👍 / 👎.

});

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
Expand All @@ -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: {
Expand Down Expand Up @@ -425,4 +445,3 @@ const useAuthStore = create(
);

export default useAuthStore;

33 changes: 23 additions & 10 deletions backend/auth_cookie.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -144,25 +161,21 @@ 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 {},
}
)
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:
Expand All @@ -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)
Comment on lines 176 to 186
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revoke the Supabase session on logout

When /auth/logout is used for the HttpOnly-cookie session, this now only deletes browser cookies and no longer extracts the current token to call client.auth.sign_out(token). In any context where the token is still available outside the cookie jar (for example the existing bearer fallback in extract_token, mobile/native clients, or a copied refresh/access token), the Supabase session remains valid after logout instead of being invalidated server-side as before.

Useful? React with 👍 / 👎.

return {"ok": True}
return {"message": "Logged out"}


@router.get("/me")
Expand Down