Skip to content

Commit 70568bd

Browse files
authored
Merge pull request #969 from pendulum-chain/copilot/sub-pr-912
Address code review comments: security, performance, and bug fixes
2 parents 5197de3 + ca868ef commit 70568bd

File tree

13 files changed

+194
-113
lines changed

13 files changed

+194
-113
lines changed

apps/api/src/api/controllers/brla.controller.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,14 @@ export const recordInitialKycAttempt = async (
205205
});
206206

207207
if (!taxIdRecord) {
208+
// Validate that user is authenticated since userId is required in the schema
209+
if (!req.userId) {
210+
res.status(httpStatus.UNAUTHORIZED).json({
211+
error: "Authentication required to record KYC attempt"
212+
});
213+
return;
214+
}
215+
208216
const accountType = isValidCnpj(taxId)
209217
? AveniaAccountType.COMPANY
210218
: isValidCpf(taxId)
@@ -220,9 +228,6 @@ export const recordInitialKycAttempt = async (
220228
internalStatus: TaxIdInternalStatus.Consulted,
221229
subAccountId: "",
222230
taxId,
223-
// @ts-ignore: Assume userId is passed in body for now, or use empty string if logic permits (but schema is NOT NULL)
224-
// Actually, if Auth is first, we should have userId.
225-
// Using a placeholder assertion as we can't change the request type easily here without bigger refactor.
226231
userId: req.userId
227232
});
228233
}

apps/api/src/api/middlewares/supabaseAuth.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextFunction, Request, Response } from "express";
2+
import logger from "../../config/logger";
23
import { SupabaseAuthService } from "../services/auth";
34

45
declare global {
@@ -60,6 +61,17 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
6061

6162
next();
6263
} catch (error) {
64+
// Log truncated token for security - only show first/last few characters
65+
const authHeader = req.headers.authorization;
66+
const truncatedAuth = authHeader
67+
? `${authHeader.substring(0, 15)}...${authHeader.substring(authHeader.length - 4)}`
68+
: undefined;
69+
70+
logger.warn("optionalAuth middleware: authentication error", {
71+
authorization: truncatedAuth,
72+
error,
73+
path: req.path
74+
});
6375
next();
6476
}
6577
}

apps/api/src/api/services/auth/supabase.service.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,22 @@ export class SupabaseAuthService {
66
*/
77
static async checkUserExists(email: string): Promise<boolean> {
88
try {
9-
const { data, error } = await supabaseAdmin.auth.admin.listUsers();
9+
// Query the profiles table directly for better performance
10+
const { data, error } = await supabaseAdmin
11+
.from("profiles")
12+
.select("id")
13+
.eq("email", email)
14+
.single();
1015

1116
if (error) {
17+
// If error is "PGRST116" (no rows returned), user doesn't exist
18+
if (error.code === "PGRST116") {
19+
return false;
20+
}
1221
throw error;
1322
}
1423

15-
const userExists = data.users.some(user => user.email === email);
16-
return userExists;
24+
return !!data;
1725
} catch (error) {
1826
console.error("Error checking user existence:", error);
1927
throw error;

apps/api/src/database/migrations/013-fix-tax-ids-table.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,19 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
1111
END IF;
1212
END $$;
1313
14-
-- Add the 'COMPANY' value to the existing enum type safely
15-
ALTER TYPE "enum_tax_ids_account_type" ADD VALUE IF NOT EXISTS 'COMPANY';
14+
-- Add the 'COMPANY' value to the existing enum type safely (compatible with PostgreSQL <12)
15+
DO $$
16+
BEGIN
17+
IF NOT EXISTS (
18+
SELECT 1 FROM pg_enum
19+
WHERE enumlabel = 'COMPANY'
20+
AND enumtypid = (
21+
SELECT oid FROM pg_type WHERE typname = 'enum_tax_ids_account_type'
22+
)
23+
) THEN
24+
ALTER TYPE "enum_tax_ids_account_type" ADD VALUE 'COMPANY';
25+
END IF;
26+
END $$;
1627
`);
1728
}
1829

apps/api/src/database/migrations/022-add-user-id-to-entities.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import {DataTypes, QueryInterface} from "sequelize";
22
import {v4 as uuidv4} from "uuid";
33

44
export async function up(queryInterface: QueryInterface): Promise<void> {
5-
// Generate a dummy user ID for migration
6-
const DUMMY_USER_ID = uuidv4();
5+
// Use a well-known sentinel UUID for the migration placeholder user
6+
// This UUID is specifically reserved for migration purposes
7+
const DUMMY_USER_ID = "00000000-0000-0000-0000-000000000001";
78

8-
console.log(`Using dummy user ID for migration: ${DUMMY_USER_ID}`);
9+
console.log(`Using sentinel migration user ID: ${DUMMY_USER_ID}`);
910

1011
// Add user_id to kyc_level_2
1112
await queryInterface.addColumn("kyc_level_2", "user_id", {
@@ -14,10 +15,11 @@ export async function up(queryInterface: QueryInterface): Promise<void> {
1415
});
1516

1617
// Insert dummy user to satisfy foreign key constraint
18+
// Use ON CONFLICT to handle cases where migration is re-run
1719
const timestamp = new Date().toISOString();
1820
await queryInterface.sequelize.query(`
1921
INSERT INTO profiles (id, email, created_at, updated_at)
20-
VALUES ('${DUMMY_USER_ID}', 'migration_placeholder_${DUMMY_USER_ID}@example.com', '${timestamp}', '${timestamp}')
22+
VALUES ('${DUMMY_USER_ID}', 'migration_placeholder@vortex.internal', '${timestamp}', '${timestamp}')
2123
ON CONFLICT (id) DO NOTHING;
2224
`);
2325

apps/frontend/src/components/widget-steps/AuthEmailStep/index.tsx

Lines changed: 33 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -41,51 +41,48 @@ export const AuthEmailStep = ({ className }: AuthEmailStepProps) => {
4141
className={cn("relative flex min-h-[506px] grow flex-col", className)}
4242
style={{ "--quote-summary-height": `${quoteSummaryHeight}px` } as React.CSSProperties}
4343
>
44-
<div className="flex-1 pb-36">
44+
<form className="flex flex-1 flex-col pb-36" onSubmit={handleSubmit}>
4545
<div className="mt-4 text-center">
4646
<h1 className="mb-4 font-bold text-3xl text-blue-700">Enter Your Email</h1>
47-
<p className="text-gray-600 mb-6">We'll send you a one-time code to verify your identity</p>
47+
<p className="mb-6 text-gray-600">We'll send you a one-time code to verify your identity</p>
4848
</div>
4949

5050
<div className="flex flex-col items-center">
51-
<div className="w-full max-w-md">
52-
<form className="space-y-4" onSubmit={handleSubmit}>
53-
<div>
54-
<label className="block text-sm font-medium text-gray-700 mb-2" htmlFor="email">
55-
Email Address
56-
</label>
57-
<input
58-
autoFocus
59-
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
60-
disabled={isLoading}
61-
id="email"
62-
onChange={e => setEmail(e.target.value)}
63-
placeholder="you@example.com"
64-
type="email"
65-
value={email}
66-
/>
67-
{(localError || errorMessage) && <p className="mt-2 text-sm text-red-600">{localError || errorMessage}</p>}
68-
</div>
69-
</form>
51+
<div className="w-full max-w-md space-y-4">
52+
<div>
53+
<label className="mb-2 block font-medium text-gray-700 text-sm" htmlFor="email">
54+
Email Address
55+
</label>
56+
<input
57+
autoFocus
58+
className="w-full rounded-lg border border-gray-300 px-4 py-3 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
59+
disabled={isLoading}
60+
id="email"
61+
onChange={e => setEmail(e.target.value)}
62+
placeholder="you@example.com"
63+
type="email"
64+
value={email}
65+
/>
66+
{(localError || errorMessage) && <p className="mt-2 text-red-600 text-sm">{localError || errorMessage}</p>}
67+
</div>
7068
</div>
7169
</div>
72-
</div>
7370

74-
<div
75-
className="absolute right-0 left-0 z-[5] flex flex-col items-center mb-4"
76-
style={{ bottom: `calc(var(--quote-summary-height, 100px) + 2rem)` }}
77-
>
78-
<div className="w-full max-w-md">
79-
<button
80-
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
81-
disabled={isLoading}
82-
onClick={handleSubmit}
83-
type="button"
84-
>
85-
{isLoading ? "Sending..." : "Continue"}
86-
</button>
71+
<div
72+
className="absolute right-0 left-0 z-[5] mb-4 flex flex-col items-center"
73+
style={{ bottom: `calc(var(--quote-summary-height, 100px) + 2rem)` }}
74+
>
75+
<div className="w-full max-w-md">
76+
<button
77+
className="w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
78+
disabled={isLoading}
79+
type="submit"
80+
>
81+
{isLoading ? "Sending..." : "Continue"}
82+
</button>
83+
</div>
8784
</div>
88-
</div>
85+
</form>
8986

9087
{quote && <QuoteSummary onHeightChange={setQuoteSummaryHeight} quote={quote} />}
9188
</div>

apps/frontend/src/hooks/useAuthTokens.ts

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useSelector } from "@xstate/react";
2-
import { useCallback, useEffect } from "react";
2+
import { useCallback, useEffect, useRef } from "react";
33
import type { ActorRefFrom } from "xstate";
44
import { supabase } from "../config/supabase";
55
import type { rampMachine } from "../machines/ramp.machine";
@@ -12,25 +12,39 @@ export function useAuthTokens(actorRef: ActorRefFrom<typeof rampMachine>) {
1212
userId: state.context.userId
1313
}));
1414

15+
// Track if we've already restored the session to avoid running multiple times
16+
const hasRestoredSession = useRef(false);
17+
1518
// Check for tokens in URL on mount (magic link callback)
1619
useEffect(() => {
1720
const urlTokens = AuthService.handleUrlTokens();
1821
if (urlTokens) {
19-
supabase.auth.getSession().then(({ data }) => {
20-
if (data.session) {
21-
const tokens = {
22-
access_token: data.session.access_token,
23-
refresh_token: data.session.refresh_token,
24-
user_id: data.session.user.id
25-
};
22+
// Use the URL tokens to set session with Supabase, then get full user details
23+
supabase.auth
24+
.setSession({
25+
access_token: urlTokens.accessToken,
26+
refresh_token: urlTokens.refreshToken
27+
})
28+
.then(({ data, error }) => {
29+
if (error) {
30+
console.error("Failed to set session from URL tokens:", error);
31+
return;
32+
}
33+
34+
if (data.session) {
35+
const tokens = {
36+
accessToken: data.session.access_token,
37+
refreshToken: data.session.refresh_token,
38+
userId: data.session.user.id
39+
};
2640

27-
AuthService.storeTokens(tokens);
28-
actorRef.send({ tokens, type: "AUTH_SUCCESS" });
41+
AuthService.storeTokens(tokens);
42+
actorRef.send({ tokens, type: "AUTH_SUCCESS" });
2943

30-
// Clean URL
31-
window.history.replaceState({}, "", window.location.pathname);
32-
}
33-
});
44+
// Clean URL
45+
window.history.replaceState({}, "", window.location.pathname);
46+
}
47+
});
3448
}
3549
}, [actorRef]);
3650

@@ -42,16 +56,20 @@ export function useAuthTokens(actorRef: ActorRefFrom<typeof rampMachine>) {
4256

4357
// Restore session from localStorage on mount
4458
useEffect(() => {
45-
const tokens = AuthService.getTokens();
46-
if (tokens && !isAuthenticated) {
47-
actorRef.send({
48-
tokens: {
49-
access_token: tokens.access_token,
50-
refresh_token: tokens.refresh_token,
51-
user_id: tokens.user_id
52-
},
53-
type: "AUTH_SUCCESS"
54-
});
59+
// Only restore once on initial mount to avoid infinite loops
60+
if (!hasRestoredSession.current && !isAuthenticated) {
61+
const tokens = AuthService.getTokens();
62+
if (tokens) {
63+
hasRestoredSession.current = true;
64+
actorRef.send({
65+
tokens: {
66+
accessToken: tokens.accessToken,
67+
refreshToken: tokens.refreshToken,
68+
userId: tokens.userId
69+
},
70+
type: "AUTH_SUCCESS"
71+
});
72+
}
5573
}
5674
}, [actorRef, isAuthenticated]);
5775

apps/frontend/src/machines/ramp.machine.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ const getInitialAuthState = () => {
3232
if (tokens) {
3333
const authState = {
3434
isAuthenticated: true,
35-
userEmail: tokens.user_email,
36-
userId: tokens.user_id
35+
userEmail: tokens.userEmail,
36+
userId: tokens.userId
3737
};
3838
return authState;
3939
}
@@ -145,7 +145,7 @@ export type RampMachineEvents =
145145
| { type: "EMAIL_VERIFIED" }
146146
| { type: "OTP_SENT" }
147147
| { type: "VERIFY_OTP"; code: string }
148-
| { type: "AUTH_SUCCESS"; tokens: { access_token: string; refresh_token: string; user_id: string } }
148+
| { type: "AUTH_SUCCESS"; tokens: { accessToken: string; refreshToken: string; userId: string } }
149149
| { type: "AUTH_ERROR"; error: string }
150150
| { type: "CHANGE_EMAIL" }
151151
| { type: "LOGOUT" };
@@ -244,7 +244,7 @@ export const rampMachine = setup({
244244
AUTH_SUCCESS: {
245245
actions: assign({
246246
isAuthenticated: true,
247-
userId: ({ event }) => event.tokens.user_id
247+
userId: ({ event }) => event.tokens.userId
248248
})
249249
},
250250
EXPIRE_QUOTE: {
@@ -706,15 +706,15 @@ export const rampMachine = setup({
706706
assign({
707707
errorMessage: undefined,
708708
isAuthenticated: true,
709-
userId: ({ event }) => event.output.user_id
709+
userId: ({ event }) => event.output.userId
710710
}),
711711
({ event, context }) => {
712712
// Store tokens in localStorage for session persistence
713713
AuthService.storeTokens({
714-
access_token: event.output.access_token,
715-
refresh_token: event.output.refresh_token,
716-
user_email: context.userEmail,
717-
user_id: event.output.user_id
714+
accessToken: event.output.accessToken,
715+
refreshToken: event.output.refreshToken,
716+
userEmail: context.userEmail,
717+
userId: event.output.userId
718718
});
719719
}
720720
],

apps/frontend/src/machines/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export type RampMachineEvents =
7171
| { type: "EMAIL_VERIFIED" }
7272
| { type: "OTP_SENT" }
7373
| { type: "VERIFY_OTP"; code: string }
74-
| { type: "AUTH_SUCCESS"; tokens: { access_token: string; refresh_token: string; user_id: string } }
74+
| { type: "AUTH_SUCCESS"; tokens: { accessToken: string; refreshToken: string; userId: string } }
7575
| { type: "AUTH_ERROR"; error: string };
7676

7777
export type RampMachineActor = ActorRef<any, RampMachineEvents>;

apps/frontend/src/services/api/api-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ apiClient.interceptors.request.use(
2020
config => {
2121
// Add Authorization header if user is authenticated
2222
const tokens = AuthService.getTokens();
23-
if (tokens?.access_token) {
24-
config.headers.Authorization = `Bearer ${tokens.access_token}`;
23+
if (tokens?.accessToken) {
24+
config.headers.Authorization = `Bearer ${tokens.accessToken}`;
2525
}
2626
return config;
2727
},

0 commit comments

Comments
 (0)