native auth
This commit is contained in:
@@ -2,8 +2,8 @@ import { CursorPositionDto } from '../dto/cursor-position.dto';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { StateGateway } from './state.gateway';
|
||||
import { AuthenticatedSocket } from '../../types/socket';
|
||||
import { AuthService } from '../../auth/auth.service';
|
||||
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { UserSocketService } from './user-socket.service';
|
||||
import { WsNotificationService } from './ws-notification.service';
|
||||
@@ -12,15 +12,13 @@ import { WsException } from '@nestjs/websockets';
|
||||
|
||||
import { UserStatusDto, UserState } from '../dto/user-status.dto';
|
||||
|
||||
interface MockSocket extends Partial<AuthenticatedSocket> {
|
||||
type MockSocket = {
|
||||
id: string;
|
||||
data: {
|
||||
user?: {
|
||||
keycloakSub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
picture?: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
};
|
||||
userId?: string;
|
||||
activeDollId?: string | null;
|
||||
@@ -29,7 +27,7 @@ interface MockSocket extends Partial<AuthenticatedSocket> {
|
||||
handshake?: any;
|
||||
disconnect?: jest.Mock;
|
||||
emit?: jest.Mock;
|
||||
}
|
||||
};
|
||||
|
||||
describe('StateGateway', () => {
|
||||
let gateway: StateGateway;
|
||||
@@ -41,7 +39,7 @@ describe('StateGateway', () => {
|
||||
sockets: { sockets: { size: number; get: jest.Mock } };
|
||||
to: jest.Mock;
|
||||
};
|
||||
let mockAuthService: Partial<AuthService>;
|
||||
let mockUsersService: Partial<UsersService>;
|
||||
let mockJwtVerificationService: Partial<JwtVerificationService>;
|
||||
let mockPrismaService: Partial<PrismaService>;
|
||||
let mockUserSocketService: Partial<UserSocketService>;
|
||||
@@ -69,16 +67,15 @@ describe('StateGateway', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
mockAuthService = {
|
||||
syncUserFromToken: jest.fn().mockResolvedValue({
|
||||
mockUsersService = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 'user-id',
|
||||
keycloakSub: 'test-sub',
|
||||
}),
|
||||
};
|
||||
|
||||
mockJwtVerificationService = {
|
||||
extractToken: jest.fn((handshake) => handshake.auth?.token),
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
verifyToken: jest.fn().mockReturnValue({
|
||||
sub: 'test-sub',
|
||||
email: 'test@example.com',
|
||||
}),
|
||||
@@ -122,7 +119,7 @@ describe('StateGateway', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
StateGateway,
|
||||
{ provide: AuthService, useValue: mockAuthService },
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
{
|
||||
provide: JwtVerificationService,
|
||||
useValue: mockJwtVerificationService,
|
||||
@@ -172,7 +169,7 @@ describe('StateGateway', () => {
|
||||
});
|
||||
|
||||
describe('handleConnection', () => {
|
||||
it('should verify token and set basic user data (but NOT sync DB)', async () => {
|
||||
it('should verify token and set basic user data (but NOT sync DB)', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {},
|
||||
@@ -183,9 +180,7 @@ describe('StateGateway', () => {
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
|
||||
await gateway.handleConnection(
|
||||
mockClient as unknown as AuthenticatedSocket,
|
||||
);
|
||||
gateway.handleConnection(mockClient as unknown as AuthenticatedSocket);
|
||||
|
||||
expect(mockJwtVerificationService.extractToken).toHaveBeenCalledWith(
|
||||
mockClient.handshake,
|
||||
@@ -195,13 +190,13 @@ describe('StateGateway', () => {
|
||||
);
|
||||
|
||||
// Should NOT call these anymore in handleConnection
|
||||
expect(mockAuthService.syncUserFromToken).not.toHaveBeenCalled();
|
||||
expect(mockUsersService.findOne).not.toHaveBeenCalled();
|
||||
expect(mockUserSocketService.setSocket).not.toHaveBeenCalled();
|
||||
|
||||
// Should set data on client
|
||||
expect(mockClient.data.user).toEqual(
|
||||
expect.objectContaining({
|
||||
keycloakSub: 'test-sub',
|
||||
userId: 'test-sub',
|
||||
}),
|
||||
);
|
||||
expect(mockClient.data.activeDollId).toBeNull();
|
||||
@@ -211,7 +206,7 @@ describe('StateGateway', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should disconnect client when no token provided', async () => {
|
||||
it('should disconnect client when no token provided', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {},
|
||||
@@ -226,9 +221,7 @@ describe('StateGateway', () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await gateway.handleConnection(
|
||||
mockClient as unknown as AuthenticatedSocket,
|
||||
);
|
||||
gateway.handleConnection(mockClient as unknown as AuthenticatedSocket);
|
||||
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
'WebSocket connection attempt without token',
|
||||
@@ -242,7 +235,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
friends: new Set(),
|
||||
},
|
||||
emit: jest.fn(),
|
||||
@@ -262,10 +255,8 @@ describe('StateGateway', () => {
|
||||
mockClient as unknown as AuthenticatedSocket,
|
||||
);
|
||||
|
||||
// 1. Sync User
|
||||
expect(mockAuthService.syncUserFromToken).toHaveBeenCalledWith(
|
||||
mockClient.data.user,
|
||||
);
|
||||
// 1. Load User
|
||||
expect(mockUsersService.findOne).toHaveBeenCalledWith('test-sub');
|
||||
|
||||
// 2. Set Socket
|
||||
expect(mockUserSocketService.setSocket).toHaveBeenCalledWith(
|
||||
@@ -320,7 +311,7 @@ describe('StateGateway', () => {
|
||||
it('should log client disconnection', async () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: { user: { keycloakSub: 'test-sub' } },
|
||||
data: { user: { userId: 'test-sub', email: 'test@example.com' } },
|
||||
};
|
||||
|
||||
await gateway.handleDisconnect(
|
||||
@@ -351,7 +342,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-id',
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -385,7 +376,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: 'doll-1', // User must have active doll
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -422,7 +413,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: null, // No doll
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -443,7 +434,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
// userId is missing
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -482,7 +473,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: 'doll-1', // User must have active doll
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -523,7 +514,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: null, // No doll
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -551,7 +542,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
// userId is missing
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -601,7 +592,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: 'doll-1',
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -652,7 +643,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub', name: 'TestUser' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -677,7 +668,6 @@ describe('StateGateway', () => {
|
||||
'interaction-received',
|
||||
expect.objectContaining({
|
||||
senderUserId: 'user-1',
|
||||
senderName: 'TestUser',
|
||||
content: 'hello',
|
||||
type: 'text',
|
||||
}),
|
||||
@@ -688,7 +678,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -719,7 +709,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
REDIS_SUBSCRIBER_CLIENT,
|
||||
} from '../../database/redis.module';
|
||||
import type { AuthenticatedSocket } from '../../types/socket';
|
||||
import { AuthService } from '../../auth/auth.service';
|
||||
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
||||
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
||||
import { UserStatusDto } from '../dto/user-status.dto';
|
||||
@@ -25,6 +24,7 @@ import { PrismaService } from '../../database/prisma.service';
|
||||
import { UserSocketService } from './user-socket.service';
|
||||
import { WsNotificationService } from './ws-notification.service';
|
||||
import { WS_EVENT, REDIS_CHANNEL } from './ws-events';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
const USER_STATUS_BROADCAST_THROTTLING_MS = 200;
|
||||
|
||||
@@ -43,9 +43,9 @@ export class StateGateway
|
||||
@WebSocketServer() io: Server;
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly jwtVerificationService: JwtVerificationService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly userSocketService: UserSocketService,
|
||||
private readonly wsNotificationService: WsNotificationService,
|
||||
@Inject(REDIS_CLIENT) private readonly redisClient: Redis | null,
|
||||
@@ -118,7 +118,7 @@ export class StateGateway
|
||||
}
|
||||
}
|
||||
|
||||
async handleConnection(client: AuthenticatedSocket) {
|
||||
handleConnection(client: AuthenticatedSocket) {
|
||||
try {
|
||||
this.logger.debug(
|
||||
`Connection attempt - handshake auth: ${JSON.stringify(client.handshake.auth)}`,
|
||||
@@ -135,18 +135,16 @@ export class StateGateway
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await this.jwtVerificationService.verifyToken(token);
|
||||
const payload = this.jwtVerificationService.verifyToken(token);
|
||||
|
||||
if (!payload.sub) {
|
||||
throw new WsException('Invalid token: missing subject');
|
||||
}
|
||||
|
||||
client.data.user = {
|
||||
keycloakSub: payload.sub,
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
username: payload.preferred_username,
|
||||
picture: payload.picture,
|
||||
roles: payload.roles,
|
||||
};
|
||||
|
||||
// Initialize defaults
|
||||
@@ -186,17 +184,15 @@ export class StateGateway
|
||||
throw new WsException('Unauthorized: No user data found');
|
||||
}
|
||||
|
||||
const payload = await this.jwtVerificationService.verifyToken(token);
|
||||
const payload = this.jwtVerificationService.verifyToken(token);
|
||||
if (!payload.sub) {
|
||||
throw new WsException('Invalid token: missing subject');
|
||||
}
|
||||
|
||||
userTokenData = {
|
||||
keycloakSub: payload.sub,
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
username: payload.preferred_username,
|
||||
picture: payload.picture,
|
||||
roles: payload.roles,
|
||||
};
|
||||
client.data.user = userTokenData;
|
||||
|
||||
@@ -209,8 +205,7 @@ export class StateGateway
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Sync user from token (DB Write/Read)
|
||||
const user = await this.authService.syncUserFromToken(userTokenData);
|
||||
const user = await this.usersService.findOne(userTokenData.userId);
|
||||
|
||||
// 2. Register socket mapping (Redis Write)
|
||||
await this.userSocketService.setSocket(user.id, client.id);
|
||||
@@ -283,7 +278,7 @@ export class StateGateway
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Client id: ${client.id} disconnected (user: ${user?.keycloakSub || 'unknown'})`,
|
||||
`Client id: ${client.id} disconnected (user: ${user?.userId || 'unknown'})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,9 +433,15 @@ export class StateGateway
|
||||
}
|
||||
|
||||
// 3. Construct payload
|
||||
const sender = await this.prisma.user.findUnique({
|
||||
where: { id: currentUserId },
|
||||
select: { name: true, username: true },
|
||||
});
|
||||
const senderName = sender?.name || sender?.username || 'Unknown';
|
||||
|
||||
const payload: InteractionPayloadDto = {
|
||||
senderUserId: currentUserId,
|
||||
senderName: user.name || user.username || 'Unknown',
|
||||
senderName,
|
||||
content: data.content,
|
||||
type: data.type,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -3,11 +3,17 @@ import { StateGateway } from './state/state.gateway';
|
||||
import { WsNotificationService } from './state/ws-notification.service';
|
||||
import { UserSocketService } from './state/user-socket.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { FriendsModule } from '../friends/friends.module';
|
||||
import { RedisModule } from '../database/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, RedisModule, forwardRef(() => FriendsModule)],
|
||||
imports: [
|
||||
AuthModule,
|
||||
forwardRef(() => UsersModule),
|
||||
RedisModule,
|
||||
forwardRef(() => FriendsModule),
|
||||
],
|
||||
providers: [StateGateway, WsNotificationService, UserSocketService],
|
||||
exports: [StateGateway, WsNotificationService, UserSocketService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user