Skip to content

Commit b165734

Browse files
hard: registration wallet-existence checks are TOCTOU — concurrent registers create duplicate identities
1 parent ca2f4a7 commit b165734

6 files changed

Lines changed: 367 additions & 37 deletions

File tree

context/progress-tracker.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pure chore/docs commits). Direct pushes to main must also be logged here.
66

77
---
88

9+
<<<<<<< Updated upstream
910
## 2026-08-24
1011

1112
- **Session families + refresh-token replay detection** (`sessions.family_id`
@@ -26,6 +27,15 @@ pure chore/docs commits). Direct pushes to main must also be logged here.
2627
- Tests: refresh-family rotation, replay → family-wide revocation + audit
2728
event, blocked-user denial within TTL bound, cache expiry re-query,
2829
cleanup job deletes-only-expired.
30+
=======
31+
## 2026-08-26
32+
33+
- Fixed registration race conditions in `AuthService.register()` by eliminating application-side pre-checks (`findByWallet`, `checkUsernameExists`) and relying directly on DB-level UNIQUE constraints (`users.wallet_address`, `users.username`).
34+
- Added idempotent migration `20260826130000_ensure_users_unique_constraints.sql` to ensure unique indexes exist on `users.wallet_address` and `users.username`.
35+
- Updated `UsersRepository.createProfile()` to catch PostgreSQL unique constraint violation error `23505` and map to structured 409 `ConflictException` (`AUTH_WALLET_EXISTS`, `AUTH_USERNAME_TAKEN`).
36+
- Added cleanup handlers (`deleteAvatar`, `deleteUserById`) in `AuthService.register()` and `UsersRepository` to ensure failed registrations do not leave orphaned avatar files or partial user records.
37+
- Added unit tests covering DB unique constraint error mapping, parallel race conditions for duplicate wallet and username registrations, sequential re-registration compatibility, and avatar/user cleanup on failure.
38+
>>>>>>> Stashed changes
2939
3040
## 2026-07-23
3141

src/database/repositories/users.repository.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, InternalServerErrorException } from '@nestjs/common';
1+
import { Injectable, InternalServerErrorException, ConflictException } from '@nestjs/common';
22
import { SupabaseService } from '../supabase.client';
33
import { UpdateUserDto } from '../../modules/users/dto/update-user.dto';
44

@@ -260,6 +260,19 @@ export class UsersRepository {
260260
.single();
261261

262262
if (error) {
263+
const combinedErr = `${error.code || ''} ${error.message || ''} ${error.details || ''} ${error.hint || ''}`;
264+
if (error.code === '23505' || combinedErr.includes('duplicate key') || combinedErr.includes('unique constraint')) {
265+
if (combinedErr.includes('username')) {
266+
throw new ConflictException({
267+
code: 'AUTH_USERNAME_TAKEN',
268+
message: 'Username is already taken.',
269+
});
270+
}
271+
throw new ConflictException({
272+
code: 'AUTH_WALLET_EXISTS',
273+
message: 'Wallet address is already registered.',
274+
});
275+
}
263276
throw new InternalServerErrorException({
264277
code: 'DATABASE_INSERT_ERROR',
265278
message: `Failed to create user profile: ${error.message}`,
@@ -292,4 +305,25 @@ export class UsersRepository {
292305
const { data } = client.storage.from('avatars').getPublicUrl(fileName);
293306
return data.publicUrl;
294307
}
308+
309+
async deleteAvatar(avatarUrl: string): Promise<void> {
310+
try {
311+
const fileName = avatarUrl.substring(avatarUrl.lastIndexOf('/') + 1);
312+
if (!fileName) return;
313+
const client = this.supabaseService.getServiceRoleClient();
314+
await client.storage.from('avatars').remove([fileName]);
315+
} catch {
316+
// Ignore cleanup failures
317+
}
318+
}
319+
320+
async deleteUserById(id: string): Promise<void> {
321+
try {
322+
const client = this.supabaseService.getServiceRoleClient();
323+
await client.from('users').delete().eq('id', id);
324+
} catch {
325+
// Ignore cleanup failures
326+
}
327+
}
295328
}
329+

src/modules/auth/auth.service.ts

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -55,36 +55,42 @@ export class AuthService {
5555
) {}
5656

5757
async register(dto: RegisterRequestDto, profileImage?: UploadedAvatarFile): Promise<RegisterResponse> {
58-
const existingWallet = await this.usersRepository.findByWallet(dto.walletAddress);
59-
if (existingWallet) {
60-
throw new ConflictException({ code: 'AUTH_WALLET_EXISTS', message: 'Wallet address is already registered.' });
61-
}
62-
const usernameTaken = await this.usersRepository.checkUsernameExists(dto.username);
63-
if (usernameTaken) {
64-
throw new ConflictException({ code: 'AUTH_USERNAME_TAKEN', message: 'Username is already taken.' });
65-
}
6658
let avatarUrl: string | null = null;
67-
if (profileImage) {
68-
avatarUrl = await this.usersRepository.uploadAvatar(dto.walletAddress, profileImage);
59+
let createdUserId: string | null = null;
60+
try {
61+
if (profileImage) {
62+
avatarUrl = await this.usersRepository.uploadAvatar(dto.walletAddress, profileImage);
63+
}
64+
const user = await this.usersRepository.createProfile({
65+
wallet: dto.walletAddress,
66+
username: dto.username,
67+
displayName: dto.displayName,
68+
avatarUrl,
69+
});
70+
createdUserId = user.id;
71+
72+
const tokens = await this.generateTokens(dto.walletAddress);
73+
74+
return {
75+
user: {
76+
id: user.id,
77+
walletAddress: user.wallet_address,
78+
username: user.username,
79+
displayName: user.display_name,
80+
avatarUrl: user.avatar_url,
81+
createdAt: user.created_at,
82+
},
83+
...tokens,
84+
};
85+
} catch (error) {
86+
if (avatarUrl) {
87+
await this.usersRepository.deleteAvatar(avatarUrl).catch(() => {});
88+
}
89+
if (createdUserId) {
90+
await this.usersRepository.deleteUserById(createdUserId).catch(() => {});
91+
}
92+
throw error;
6993
}
70-
const user = await this.usersRepository.createProfile({
71-
wallet: dto.walletAddress,
72-
username: dto.username,
73-
displayName: dto.displayName,
74-
avatarUrl,
75-
});
76-
const tokens = await this.generateTokens(dto.walletAddress);
77-
return {
78-
user: {
79-
id: user.id,
80-
walletAddress: user.wallet_address,
81-
username: user.username,
82-
displayName: user.display_name,
83-
avatarUrl: user.avatar_url,
84-
createdAt: user.created_at,
85-
},
86-
...tokens,
87-
};
8894
}
8995

9096
async generateNonce(wallet: string): Promise<NonceResponseDto> {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Ensure DB-level UNIQUE indexes exist on users.wallet_address and users.username
2+
3+
CREATE UNIQUE INDEX IF NOT EXISTS users_wallet_address_idx ON public.users (wallet_address);
4+
CREATE UNIQUE INDEX IF NOT EXISTS users_username_idx ON public.users (username);

test/unit/modules/auth/auth.service.spec.ts

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ describe('AuthService', () => {
4242
checkUsernameExists: jest.fn(),
4343
uploadAvatar: jest.fn(),
4444
createProfile: jest.fn(),
45+
deleteAvatar: jest.fn(),
46+
deleteUserById: jest.fn(),
4547
};
4648

4749
const mockAuditService = {
@@ -458,6 +460,8 @@ describe('AuthService', () => {
458460
mockUsersRepository.checkUsernameExists.mockResolvedValue(false);
459461
mockUsersRepository.createProfile.mockResolvedValue(mockUser);
460462
mockUsersRepository.uploadAvatar.mockResolvedValue('https://example.com/avatar.png');
463+
mockUsersRepository.deleteAvatar.mockResolvedValue(undefined);
464+
mockUsersRepository.deleteUserById.mockResolvedValue(undefined);
461465

462466
// Mock findOrCreateUser internal behavior via Supabase mock
463467
mockFrom.mockImplementation((table: string) => {
@@ -492,8 +496,6 @@ describe('AuthService', () => {
492496
it('should register a new user successfully without image', async () => {
493497
const result = await service.register(registerDto);
494498

495-
expect(mockUsersRepository.findByWallet).toHaveBeenCalledWith(validWallet);
496-
expect(mockUsersRepository.checkUsernameExists).toHaveBeenCalledWith('testuser');
497499
expect(mockUsersRepository.createProfile).toHaveBeenCalledWith({
498500
wallet: validWallet,
499501
username: 'testuser',
@@ -517,23 +519,139 @@ describe('AuthService', () => {
517519
expect(result.user.avatarUrl).toBe('https://example.com/avatar.png');
518520
});
519521

520-
it('should throw ConflictException if wallet already exists', async () => {
521-
mockUsersRepository.findByWallet.mockResolvedValue({ id: 'existing' });
522+
it('should throw ConflictException (AUTH_WALLET_EXISTS) if DB unique constraint on wallet is violated', async () => {
523+
mockUsersRepository.createProfile.mockRejectedValueOnce(
524+
new ConflictException({ code: 'AUTH_WALLET_EXISTS', message: 'Wallet address is already registered.' }),
525+
);
522526

523-
await expect(service.register(registerDto)).rejects.toThrow(ConflictException);
524527
await expect(service.register(registerDto)).rejects.toMatchObject({
525528
response: { code: 'AUTH_WALLET_EXISTS' },
526529
});
527530
});
528531

529-
it('should throw ConflictException if username is taken', async () => {
530-
mockUsersRepository.checkUsernameExists.mockResolvedValue(true);
532+
it('should throw ConflictException (AUTH_USERNAME_TAKEN) if DB unique constraint on username is violated', async () => {
533+
mockUsersRepository.createProfile.mockRejectedValueOnce(
534+
new ConflictException({ code: 'AUTH_USERNAME_TAKEN', message: 'Username is already taken.' }),
535+
);
531536

532-
await expect(service.register(registerDto)).rejects.toThrow(ConflictException);
533537
await expect(service.register(registerDto)).rejects.toMatchObject({
534538
response: { code: 'AUTH_USERNAME_TAKEN' },
535539
});
536540
});
541+
542+
it('should handle parallel duplicate-wallet registrations yielding exactly one success and one 409 AUTH_WALLET_EXISTS', async () => {
543+
mockUsersRepository.createProfile
544+
.mockResolvedValueOnce(mockUser)
545+
.mockRejectedValueOnce(
546+
new ConflictException({ code: 'AUTH_WALLET_EXISTS', message: 'Wallet address is already registered.' }),
547+
);
548+
549+
const [res1, res2] = await Promise.allSettled([
550+
service.register(registerDto),
551+
service.register(registerDto),
552+
]);
553+
554+
const fulfilled = [res1, res2].filter((r) => r.status === 'fulfilled');
555+
const rejected = [res1, res2].filter((r) => r.status === 'rejected');
556+
557+
expect(fulfilled).toHaveLength(1);
558+
expect(rejected).toHaveLength(1);
559+
if (rejected[0].status === 'rejected') {
560+
expect(rejected[0].reason).toBeInstanceOf(ConflictException);
561+
expect((rejected[0].reason as ConflictException).getResponse()).toEqual({
562+
code: 'AUTH_WALLET_EXISTS',
563+
message: 'Wallet address is already registered.',
564+
});
565+
}
566+
});
567+
568+
it('should handle parallel duplicate-username registrations yielding exactly one success and one 409 AUTH_USERNAME_TAKEN', async () => {
569+
const dto2 = { ...registerDto, walletAddress: 'GDIFFERENTWALLETHDHSKDHFKSHDFKSHDFKSHDFKSHDFKSH' };
570+
571+
mockUsersRepository.createProfile
572+
.mockResolvedValueOnce(mockUser)
573+
.mockRejectedValueOnce(
574+
new ConflictException({ code: 'AUTH_USERNAME_TAKEN', message: 'Username is already taken.' }),
575+
);
576+
577+
const [res1, res2] = await Promise.allSettled([
578+
service.register(registerDto),
579+
service.register(dto2),
580+
]);
581+
582+
const fulfilled = [res1, res2].filter((r) => r.status === 'fulfilled');
583+
const rejected = [res1, res2].filter((r) => r.status === 'rejected');
584+
585+
expect(fulfilled).toHaveLength(1);
586+
expect(rejected).toHaveLength(1);
587+
if (rejected[0].status === 'rejected') {
588+
expect(rejected[0].reason).toBeInstanceOf(ConflictException);
589+
expect((rejected[0].reason as ConflictException).getResponse()).toEqual({
590+
code: 'AUTH_USERNAME_TAKEN',
591+
message: 'Username is already taken.',
592+
});
593+
}
594+
});
595+
596+
it('should return same structured 409 AUTH_WALLET_EXISTS on sequential re-registration', async () => {
597+
// First registration succeeds
598+
await service.register(registerDto);
599+
600+
// Second registration fails on unique constraint
601+
mockUsersRepository.createProfile.mockRejectedValueOnce(
602+
new ConflictException({ code: 'AUTH_WALLET_EXISTS', message: 'Wallet address is already registered.' }),
603+
);
604+
605+
await expect(service.register(registerDto)).rejects.toMatchObject({
606+
response: { code: 'AUTH_WALLET_EXISTS' },
607+
});
608+
});
609+
610+
it('should clean up avatar from storage when registration fails after avatar upload', async () => {
611+
const mockFile = { originalname: 'avatar.png', buffer: Buffer.from('test'), mimetype: 'image/png' };
612+
mockUsersRepository.uploadAvatar.mockResolvedValue('https://example.com/avatar.png');
613+
mockUsersRepository.createProfile.mockRejectedValueOnce(
614+
new ConflictException({ code: 'AUTH_WALLET_EXISTS', message: 'Wallet address is already registered.' }),
615+
);
616+
617+
await expect(service.register(registerDto, mockFile)).rejects.toThrow(ConflictException);
618+
619+
expect(mockUsersRepository.deleteAvatar).toHaveBeenCalledWith('https://example.com/avatar.png');
620+
});
621+
622+
it('should clean up both avatar and created user if downstream token issuance fails', async () => {
623+
const mockFile = { originalname: 'avatar.png', buffer: Buffer.from('test'), mimetype: 'image/png' };
624+
mockUsersRepository.uploadAvatar.mockResolvedValue('https://example.com/avatar.png');
625+
mockUsersRepository.createProfile.mockResolvedValue(mockUser);
626+
627+
// Mock session creation failure during generateTokens
628+
mockFrom.mockImplementation((table: string) => {
629+
if (table === 'users') {
630+
return {
631+
upsert: jest.fn().mockReturnThis(),
632+
select: jest.fn().mockReturnThis(),
633+
single: jest.fn().mockResolvedValue({ data: { id: 'user-uuid', status: 'active' }, error: null }),
634+
};
635+
}
636+
if (table === 'learner_profiles') {
637+
return {
638+
select: jest.fn().mockReturnThis(),
639+
eq: jest.fn().mockReturnThis(),
640+
maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }),
641+
insert: jest.fn().mockResolvedValue({ error: null }),
642+
};
643+
}
644+
if (table === 'sessions') {
645+
return { insert: jest.fn().mockResolvedValue({ error: { message: 'Session failed' } }) };
646+
}
647+
return { insert: mockInsert };
648+
});
649+
650+
await expect(service.register(registerDto, mockFile)).rejects.toThrow(InternalServerErrorException);
651+
652+
expect(mockUsersRepository.deleteAvatar).toHaveBeenCalledWith('https://example.com/avatar.png');
653+
expect(mockUsersRepository.deleteUserById).toHaveBeenCalledWith('user-uuid');
654+
});
537655
});
538656

539657
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)