diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 79e66cab6..96595bfe1 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -205,6 +205,14 @@ export const recordInitialKycAttempt = async ( }); if (!taxIdRecord) { + // Validate that user is authenticated since userId is required in the schema + if (!req.userId) { + res.status(httpStatus.UNAUTHORIZED).json({ + error: "Authentication required to record KYC attempt" + }); + return; + } + const accountType = isValidCnpj(taxId) ? AveniaAccountType.COMPANY : isValidCpf(taxId) @@ -220,9 +228,6 @@ export const recordInitialKycAttempt = async ( internalStatus: TaxIdInternalStatus.Consulted, subAccountId: "", taxId, - // @ts-ignore: Assume userId is passed in body for now, or use empty string if logic permits (but schema is NOT NULL) - // Actually, if Auth is first, we should have userId. - // Using a placeholder assertion as we can't change the request type easily here without bigger refactor. userId: req.userId }); } diff --git a/apps/api/src/api/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts index d6f331654..59f039348 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -1,4 +1,5 @@ import { NextFunction, Request, Response } from "express"; +import logger from "../../config/logger"; import { SupabaseAuthService } from "../services/auth"; declare global { @@ -60,6 +61,17 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti next(); } catch (error) { + // Log truncated token for security - only show first/last few characters + const authHeader = req.headers.authorization; + const truncatedAuth = authHeader + ? `${authHeader.substring(0, 15)}...${authHeader.substring(authHeader.length - 4)}` + : undefined; + + logger.warn("optionalAuth middleware: authentication error", { + authorization: truncatedAuth, + error, + path: req.path + }); next(); } } diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index c8785f452..4ae48cc21 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -6,14 +6,22 @@ export class SupabaseAuthService { */ static async checkUserExists(email: string): Promise { try { - const { data, error } = await supabaseAdmin.auth.admin.listUsers(); + // Query the profiles table directly for better performance + const { data, error } = await supabaseAdmin + .from("profiles") + .select("id") + .eq("email", email) + .single(); if (error) { + // If error is "PGRST116" (no rows returned), user doesn't exist + if (error.code === "PGRST116") { + return false; + } throw error; } - const userExists = data.users.some(user => user.email === email); - return userExists; + return !!data; } catch (error) { console.error("Error checking user existence:", error); throw error; diff --git a/apps/api/src/database/migrations/013-fix-tax-ids-table.ts b/apps/api/src/database/migrations/013-fix-tax-ids-table.ts index a3044a704..9ab21e3c6 100644 --- a/apps/api/src/database/migrations/013-fix-tax-ids-table.ts +++ b/apps/api/src/database/migrations/013-fix-tax-ids-table.ts @@ -11,8 +11,19 @@ export async function up(queryInterface: QueryInterface): Promise { END IF; END $$; - -- Add the 'COMPANY' value to the existing enum type safely - ALTER TYPE "enum_tax_ids_account_type" ADD VALUE IF NOT EXISTS 'COMPANY'; + -- Add the 'COMPANY' value to the existing enum type safely (compatible with PostgreSQL <12) + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'COMPANY' + AND enumtypid = ( + SELECT oid FROM pg_type WHERE typname = 'enum_tax_ids_account_type' + ) + ) THEN + ALTER TYPE "enum_tax_ids_account_type" ADD VALUE 'COMPANY'; + END IF; + END $$; `); } diff --git a/apps/api/src/database/migrations/022-add-user-id-to-entities.ts b/apps/api/src/database/migrations/022-add-user-id-to-entities.ts index 98c521303..53a667c63 100644 --- a/apps/api/src/database/migrations/022-add-user-id-to-entities.ts +++ b/apps/api/src/database/migrations/022-add-user-id-to-entities.ts @@ -2,10 +2,11 @@ import {DataTypes, QueryInterface} from "sequelize"; import {v4 as uuidv4} from "uuid"; export async function up(queryInterface: QueryInterface): Promise { - // Generate a dummy user ID for migration - const DUMMY_USER_ID = uuidv4(); + // Use a well-known sentinel UUID for the migration placeholder user + // This UUID is specifically reserved for migration purposes + const DUMMY_USER_ID = "00000000-0000-0000-0000-000000000001"; - console.log(`Using dummy user ID for migration: ${DUMMY_USER_ID}`); + console.log(`Using sentinel migration user ID: ${DUMMY_USER_ID}`); // Add user_id to kyc_level_2 await queryInterface.addColumn("kyc_level_2", "user_id", { @@ -14,10 +15,11 @@ export async function up(queryInterface: QueryInterface): Promise { }); // Insert dummy user to satisfy foreign key constraint + // Use ON CONFLICT to handle cases where migration is re-run const timestamp = new Date().toISOString(); await queryInterface.sequelize.query(` INSERT INTO profiles (id, email, created_at, updated_at) - VALUES ('${DUMMY_USER_ID}', 'migration_placeholder_${DUMMY_USER_ID}@example.com', '${timestamp}', '${timestamp}') + VALUES ('${DUMMY_USER_ID}', 'migration_placeholder@vortex.internal', '${timestamp}', '${timestamp}') ON CONFLICT (id) DO NOTHING; `); diff --git a/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx b/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx index cda774e16..0b1b72f33 100644 --- a/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx @@ -41,51 +41,48 @@ export const AuthEmailStep = ({ className }: AuthEmailStepProps) => { className={cn("relative flex min-h-[506px] grow flex-col", className)} style={{ "--quote-summary-height": `${quoteSummaryHeight}px` } as React.CSSProperties} > -
+

Enter Your Email

-

We'll send you a one-time code to verify your identity

+

We'll send you a one-time code to verify your identity

-
- -
- - setEmail(e.target.value)} - placeholder="you@example.com" - type="email" - value={email} - /> - {(localError || errorMessage) &&

{localError || errorMessage}

} -
- +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + type="email" + value={email} + /> + {(localError || errorMessage) &&

{localError || errorMessage}

} +
-
-
-
- +
+
+ +
-
+ {quote && }
diff --git a/apps/frontend/src/hooks/useAuthTokens.ts b/apps/frontend/src/hooks/useAuthTokens.ts index 16ddfc0c3..602113bbf 100644 --- a/apps/frontend/src/hooks/useAuthTokens.ts +++ b/apps/frontend/src/hooks/useAuthTokens.ts @@ -1,5 +1,5 @@ import { useSelector } from "@xstate/react"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { ActorRefFrom } from "xstate"; import { supabase } from "../config/supabase"; import type { rampMachine } from "../machines/ramp.machine"; @@ -12,25 +12,39 @@ export function useAuthTokens(actorRef: ActorRefFrom) { userId: state.context.userId })); + // Track if we've already restored the session to avoid running multiple times + const hasRestoredSession = useRef(false); + // Check for tokens in URL on mount (magic link callback) useEffect(() => { const urlTokens = AuthService.handleUrlTokens(); if (urlTokens) { - supabase.auth.getSession().then(({ data }) => { - if (data.session) { - const tokens = { - access_token: data.session.access_token, - refresh_token: data.session.refresh_token, - user_id: data.session.user.id - }; + // Use the URL tokens to set session with Supabase, then get full user details + supabase.auth + .setSession({ + access_token: urlTokens.accessToken, + refresh_token: urlTokens.refreshToken + }) + .then(({ data, error }) => { + if (error) { + console.error("Failed to set session from URL tokens:", error); + return; + } + + if (data.session) { + const tokens = { + accessToken: data.session.access_token, + refreshToken: data.session.refresh_token, + userId: data.session.user.id + }; - AuthService.storeTokens(tokens); - actorRef.send({ tokens, type: "AUTH_SUCCESS" }); + AuthService.storeTokens(tokens); + actorRef.send({ tokens, type: "AUTH_SUCCESS" }); - // Clean URL - window.history.replaceState({}, "", window.location.pathname); - } - }); + // Clean URL + window.history.replaceState({}, "", window.location.pathname); + } + }); } }, [actorRef]); @@ -42,16 +56,20 @@ export function useAuthTokens(actorRef: ActorRefFrom) { // Restore session from localStorage on mount useEffect(() => { - const tokens = AuthService.getTokens(); - if (tokens && !isAuthenticated) { - actorRef.send({ - tokens: { - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - user_id: tokens.user_id - }, - type: "AUTH_SUCCESS" - }); + // Only restore once on initial mount to avoid infinite loops + if (!hasRestoredSession.current && !isAuthenticated) { + const tokens = AuthService.getTokens(); + if (tokens) { + hasRestoredSession.current = true; + actorRef.send({ + tokens: { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + userId: tokens.userId + }, + type: "AUTH_SUCCESS" + }); + } } }, [actorRef, isAuthenticated]); diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index b59bc9714..aac26b1c7 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -32,8 +32,8 @@ const getInitialAuthState = () => { if (tokens) { const authState = { isAuthenticated: true, - userEmail: tokens.user_email, - userId: tokens.user_id + userEmail: tokens.userEmail, + userId: tokens.userId }; return authState; } @@ -145,7 +145,7 @@ export type RampMachineEvents = | { type: "EMAIL_VERIFIED" } | { type: "OTP_SENT" } | { type: "VERIFY_OTP"; code: string } - | { type: "AUTH_SUCCESS"; tokens: { access_token: string; refresh_token: string; user_id: string } } + | { type: "AUTH_SUCCESS"; tokens: { accessToken: string; refreshToken: string; userId: string } } | { type: "AUTH_ERROR"; error: string } | { type: "CHANGE_EMAIL" } | { type: "LOGOUT" }; @@ -244,7 +244,7 @@ export const rampMachine = setup({ AUTH_SUCCESS: { actions: assign({ isAuthenticated: true, - userId: ({ event }) => event.tokens.user_id + userId: ({ event }) => event.tokens.userId }) }, EXPIRE_QUOTE: { @@ -706,15 +706,15 @@ export const rampMachine = setup({ assign({ errorMessage: undefined, isAuthenticated: true, - userId: ({ event }) => event.output.user_id + userId: ({ event }) => event.output.userId }), ({ event, context }) => { // Store tokens in localStorage for session persistence AuthService.storeTokens({ - access_token: event.output.access_token, - refresh_token: event.output.refresh_token, - user_email: context.userEmail, - user_id: event.output.user_id + accessToken: event.output.accessToken, + refreshToken: event.output.refreshToken, + userEmail: context.userEmail, + userId: event.output.userId }); } ], diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index 14b3fcccc..c9f33b61f 100644 --- a/apps/frontend/src/machines/types.ts +++ b/apps/frontend/src/machines/types.ts @@ -71,7 +71,7 @@ export type RampMachineEvents = | { type: "EMAIL_VERIFIED" } | { type: "OTP_SENT" } | { type: "VERIFY_OTP"; code: string } - | { type: "AUTH_SUCCESS"; tokens: { access_token: string; refresh_token: string; user_id: string } } + | { type: "AUTH_SUCCESS"; tokens: { accessToken: string; refreshToken: string; userId: string } } | { type: "AUTH_ERROR"; error: string }; export type RampMachineActor = ActorRef; diff --git a/apps/frontend/src/services/api/api-client.ts b/apps/frontend/src/services/api/api-client.ts index 15d9d9715..025fcfd29 100644 --- a/apps/frontend/src/services/api/api-client.ts +++ b/apps/frontend/src/services/api/api-client.ts @@ -20,8 +20,8 @@ apiClient.interceptors.request.use( config => { // Add Authorization header if user is authenticated const tokens = AuthService.getTokens(); - if (tokens?.access_token) { - config.headers.Authorization = `Bearer ${tokens.access_token}`; + if (tokens?.accessToken) { + config.headers.Authorization = `Bearer ${tokens.accessToken}`; } return config; }, diff --git a/apps/frontend/src/services/api/auth.api.ts b/apps/frontend/src/services/api/auth.api.ts index 4be943ca4..a7a1cf6ac 100644 --- a/apps/frontend/src/services/api/auth.api.ts +++ b/apps/frontend/src/services/api/auth.api.ts @@ -7,9 +7,9 @@ export interface CheckEmailResponse { export interface VerifyOTPResponse { success: boolean; - access_token: string; - refresh_token: string; - user_id: string; + accessToken: string; + refreshToken: string; + userId: string; } export class AuthAPI { @@ -36,20 +36,36 @@ export class AuthAPI { * Verify OTP */ static async verifyOTP(email: string, token: string): Promise { - const response = await apiClient.post("/auth/verify-otp", { - email, - token - }); - return response.data; + const response = await apiClient.post<{ success: boolean; access_token: string; refresh_token: string; user_id: string }>( + "/auth/verify-otp", + { + email, + token + } + ); + return { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + success: response.data.success, + userId: response.data.user_id + }; } /** * Refresh token */ static async refreshToken(refreshToken: string): Promise { - const response = await apiClient.post("/auth/refresh", { - refresh_token: refreshToken - }); - return response.data; + const response = await apiClient.post<{ success: boolean; access_token: string; refresh_token: string; user_id: string }>( + "/auth/refresh", + { + refresh_token: refreshToken + } + ); + return { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + success: response.data.success, + userId: response.data.user_id + }; } } diff --git a/apps/frontend/src/services/auth.ts b/apps/frontend/src/services/auth.ts index 19e337799..c859a5f44 100644 --- a/apps/frontend/src/services/auth.ts +++ b/apps/frontend/src/services/auth.ts @@ -1,10 +1,10 @@ import { supabase } from "../config/supabase"; export interface AuthTokens { - access_token: string; - refresh_token: string; - user_id: string; - user_email?: string; + accessToken: string; + refreshToken: string; + userId: string; + userEmail?: string; } export class AuthService { @@ -15,13 +15,18 @@ export class AuthService { /** * Store tokens in localStorage + * + * Security Note: Storing tokens in localStorage makes them vulnerable to XSS attacks. + * For production applications, consider using httpOnly cookies or implementing additional + * security measures such as Content Security Policy headers and token encryption. + * The current implementation prioritizes user experience and ease of integration. */ static storeTokens(tokens: AuthTokens): void { - localStorage.setItem(this.ACCESS_TOKEN_KEY, tokens.access_token); - localStorage.setItem(this.REFRESH_TOKEN_KEY, tokens.refresh_token); - localStorage.setItem(this.USER_ID_KEY, tokens.user_id); - if (tokens.user_email) { - localStorage.setItem(this.USER_EMAIL_KEY, tokens.user_email); + localStorage.setItem(this.ACCESS_TOKEN_KEY, tokens.accessToken); + localStorage.setItem(this.REFRESH_TOKEN_KEY, tokens.refreshToken); + localStorage.setItem(this.USER_ID_KEY, tokens.userId); + if (tokens.userEmail) { + localStorage.setItem(this.USER_EMAIL_KEY, tokens.userEmail); } } @@ -38,7 +43,7 @@ export class AuthService { return null; } - return { access_token, refresh_token, user_email: user_email || undefined, user_id }; + return { accessToken: access_token, refreshToken: refresh_token, userEmail: user_email || undefined, userId: user_id }; } /** @@ -67,14 +72,17 @@ export class AuthService { /** * Handle tokens from URL (for magic link callback) + * Returns the tokens from the URL hash if present, otherwise null. + * Note: These are raw URL tokens; the caller should use them to set up + * the Supabase session and get the full user details. */ - static handleUrlTokens(): AuthTokens | null { + static handleUrlTokens(): { accessToken: string; refreshToken: string } | null { const params = new URLSearchParams(window.location.hash.substring(1)); const access_token = params.get("access_token"); const refresh_token = params.get("refresh_token"); if (access_token && refresh_token) { - return { access_token, refresh_token, user_id: "" }; + return { accessToken: access_token, refreshToken: refresh_token }; } return null; @@ -91,7 +99,7 @@ export class AuthService { try { const { data, error } = await supabase.auth.refreshSession({ - refresh_token: tokens.refresh_token + refresh_token: tokens.refreshToken }); if (error || !data.session || !data.user) { @@ -100,9 +108,9 @@ export class AuthService { } const newTokens: AuthTokens = { - access_token: data.session.access_token, - refresh_token: data.session.refresh_token, - user_id: data.user.id + accessToken: data.session.access_token, + refreshToken: data.session.refresh_token, + userId: data.user.id }; this.storeTokens(newTokens); diff --git a/supabase/config.toml b/supabase/config.toml index a767a979e..7bd9da5c7 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -173,7 +173,7 @@ password_requirements = "" [auth.rate_limit] # Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. -email_sent = 2 +email_sent = 10 # Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. sms_sent = 30 # Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. @@ -230,6 +230,10 @@ otp_expiry = 3600 subject = "Your Vortex Verification Code" content_path = "./supabase/templates/magic_link.html" +[auth.email.template.confirmation] +subject = "Confirm your signup" +content_path = "./supabase/templates/signup.html" + # Uncomment to customize notification email template # [auth.email.notification.password_changed] # enabled = true