Skip to content
Open
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
74 changes: 74 additions & 0 deletions src/auth/dto/auth.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {

Check failure on line 1 in src/auth/dto/auth.dto.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎··IsEmail,⏎··IsString,⏎··MinLength,⏎··MaxLength,⏎··Matches,⏎··IsOptional,⏎` with `·IsEmail,·IsString,·MinLength,·MaxLength,·Matches,·IsOptional·`
IsEmail,
IsString,
MinLength,
MaxLength,
Matches,
IsOptional,

Check warning on line 7 in src/auth/dto/auth.dto.ts

View workflow job for this annotation

GitHub Actions / validate

'IsOptional' is defined but never used. Allowed unused vars must match /^_/u
} from 'class-validator';
import { Match } from '../../common/decorators/match.decorator';

export class RegisterDto {
@IsEmail({}, { message: 'email must be a valid email address' })
email: string;

@IsString()
@MinLength(8, { message: 'password must be at least 8 characters' })
@MaxLength(72, { message: 'password must be at most 72 characters' })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'password must contain at least one uppercase letter, one lowercase letter, and one number',

Check failure on line 19 in src/auth/dto/auth.dto.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `⏎·····`
})
password: string;

@IsString()
@Match('password')
confirmPassword: string;

@IsString()
@MinLength(2)
@MaxLength(50)
firstName: string;

@IsString()
@MinLength(2)
@MaxLength(50)
lastName: string;
}

export class LoginDto {
@IsEmail({}, { message: 'email must be a valid email address' })
email: string;

@IsString()
@MinLength(1, { message: 'password must not be empty' })
password: string;
}

export class RefreshTokenDto {
@IsString()
@MinLength(1)
refreshToken: string;
}

export class ForgotPasswordDto {
@IsEmail({}, { message: 'email must be a valid email address' })
email: string;
}

export class ResetPasswordDto {
@IsString()
@MinLength(1)
token: string;

@IsString()
@MinLength(8)
@MaxLength(72)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'password must contain at least one uppercase letter, one lowercase letter, and one number',

Check failure on line 67 in src/auth/dto/auth.dto.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `⏎·····`
})
password: string;

@IsString()
@Match('password')
confirmPassword: string;
}

Check failure on line 74 in src/auth/dto/auth.dto.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `⏎`
66 changes: 66 additions & 0 deletions src/auth/guards/ws-auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ExecutionContext } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { WsAuthGuard } from './ws-auth.guard';
import { WsException } from '@nestjs/websockets';

const VALID_SECRET = 'a-test-secret-that-is-long-enough-32chars';

function makeContext(token: unknown): ExecutionContext {
const client = {
id: 'socket-1',
handshake: { auth: { token } },
data: {},
disconnect: jest.fn(),
};
return {
switchToWs: () => ({ getClient: () => client }),
} as unknown as ExecutionContext;
}

describe('WsAuthGuard', () => {
let guard: WsAuthGuard;
let jwtService: JwtService;

beforeEach(() => {
jwtService = new JwtService({});
const config = { getOrThrow: jest.fn().mockReturnValue(VALID_SECRET) } as unknown as ConfigService;

Check failure on line 27 in src/auth/guards/ws-auth.guard.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·getOrThrow:·jest.fn().mockReturnValue(VALID_SECRET)` with `⏎······getOrThrow:·jest.fn().mockReturnValue(VALID_SECRET),⏎···`
guard = new WsAuthGuard(jwtService, config);
});

it('allows connection with a valid token', () => {
const token = jwtService.sign({ sub: 'user-1' }, { secret: VALID_SECRET });
const ctx = makeContext(token);
expect(guard.canActivate(ctx)).toBe(true);
});

it('rejects connection when token is missing', () => {
const ctx = makeContext(undefined);
expect(() => guard.canActivate(ctx)).toThrow(WsException);
expect((ctx.switchToWs().getClient() as any).disconnect).toHaveBeenCalledWith(true);
});

it('rejects connection when token is expired', () => {
const token = jwtService.sign(

Check failure on line 44 in src/auth/guards/ws-auth.guard.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎······{·sub:·'user-1'·},⏎······{·secret:·VALID_SECRET,·expiresIn:·'-1s'·},⏎····` with `{·sub:·'user-1'·},·{·secret:·VALID_SECRET,·expiresIn:·'-1s'·}`
{ sub: 'user-1' },
{ secret: VALID_SECRET, expiresIn: '-1s' },
);
const ctx = makeContext(token);
expect(() => guard.canActivate(ctx)).toThrow(WsException);
expect((ctx.switchToWs().getClient() as any).disconnect).toHaveBeenCalledWith(true);
});

it('rejects connection when token is signed with wrong secret', () => {
const token = jwtService.sign({ sub: 'user-1' }, { secret: 'wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxx' });

Check failure on line 54 in src/auth/guards/ws-auth.guard.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `{·sub:·'user-1'·},·{·secret:·'wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxx'·}` with `⏎······{·sub:·'user-1'·},⏎······{·secret:·'wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxx'·},⏎····`
const ctx = makeContext(token);
expect(() => guard.canActivate(ctx)).toThrow(WsException);
});

it('attaches verified payload to socket.data.user', () => {
const token = jwtService.sign({ sub: 'user-42', email: 'a@b.com' }, { secret: VALID_SECRET });
const ctx = makeContext(token);
guard.canActivate(ctx);
const client = ctx.switchToWs().getClient() as any;
expect(client.data.user.sub).toBe('user-42');
});
});

Check failure on line 66 in src/auth/guards/ws-auth.guard.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `⏎`
49 changes: 49 additions & 0 deletions src/auth/guards/ws-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { Socket } from 'socket.io';
import { WsException } from '@nestjs/websockets';

export interface JwtPayload {
sub: string | number;
email?: string;
[key: string]: unknown;
}

export type AuthenticatedSocket = Socket & {
data: { user: JwtPayload };
};

@Injectable()
export class WsAuthGuard implements CanActivate {
private readonly logger = new Logger(WsAuthGuard.name);

constructor(
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}

canActivate(context: ExecutionContext): boolean {
const client: Socket = context.switchToWs().getClient<Socket>();
const token: unknown = client.handshake?.auth?.token;

if (!token || typeof token !== 'string') {
this.logger.warn(`Connection ${client.id} rejected — no token provided`);
client.disconnect(true);
throw new WsException('Unauthorized: missing token');
}

try {
const secret = this.config.getOrThrow<string>('JWT_SECRET');
const payload = this.jwtService.verify<JwtPayload>(token, { secret });
// Attach verified identity to socket for downstream handlers
(client as AuthenticatedSocket).data = { user: payload };
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'token verification failed';
this.logger.warn(`Connection ${client.id} rejected — ${message}`);
client.disconnect(true);
throw new WsException(`Unauthorized: ${message}`);
}
}
}

Check failure on line 49 in src/auth/guards/ws-auth.guard.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `⏎`
108 changes: 98 additions & 10 deletions src/collaboration/collaboration.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,99 @@
import { Module } from '@nestjs/common';
import { OtCrdtService } from './ot-crdt.service';
import { PresenceService } from './presence.service';
import { ChangeHistoryService } from './change-history.service';
import { CollaborationGateway } from './collaboration.gateway';

@Module({
providers: [OtCrdtService, PresenceService, ChangeHistoryService, CollaborationGateway],
exports: [OtCrdtService, PresenceService, ChangeHistoryService],
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
UseGuards,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { ConfigService } from '@nestjs/config';
import { Logger } from '@nestjs/common';
import { WsAuthGuard, AuthenticatedSocket } from '../auth/guards/ws-auth.guard';

function resolveAllowedOrigins(config: ConfigService): string[] {
const raw = config.get<string>('WS_ALLOWED_ORIGINS', '');
return raw
.split(',')
.map((o) => o.trim())
.filter(Boolean);
}

@WebSocketGateway({
namespace: '/collaboration',
cors: {
// Origin callback evaluated per-connection (#795)
origin(requestOrigin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) {

Check failure on line 28 in src/collaboration/collaboration.module.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `requestOrigin:·string·|·undefined,·callback:·(err:·Error·|·null,·allow?:·boolean)·=>·void` with `⏎······requestOrigin:·string·|·undefined,⏎······callback:·(err:·Error·|·null,·allow?:·boolean)·=>·void,⏎····`
// This function is replaced at runtime by CollaborationGateway.configureOrigin
// The static decorator value is overridden in the constructor via server options.
callback(null, false);
},
credentials: true,
},
})
export class CollaborationModule {}
@UseGuards(WsAuthGuard) // #796 — rejects unauthenticated connections
export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;

private readonly logger = new Logger(CollaborationGateway.name);
private readonly allowedOrigins: string[];

constructor(private readonly config: ConfigService) {
this.allowedOrigins = resolveAllowedOrigins(config);
this.logger.log(`WS allowed origins: ${this.allowedOrigins.join(', ') || '(none)'}`);
}

afterInit(server: Server): void {
const allowed = this.allowedOrigins;
// Override CORS origin function with the runtime allowlist (#795)
server.engine.on('initial_headers', () => { /* handled by origin callback below */ });
(server as any).opts = {
...(server as any).opts,
cors: {
origin(origin: string | undefined, cb: (err: Error | null, allow?: boolean) => void) {
if (!origin || allowed.includes(origin)) {
cb(null, true);
} else {
cb(new Error(`Origin "${origin}" is not allowed`), false);
}
},
credentials: true,
},
};
}

handleConnection(client: Socket): void {
// WsAuthGuard has already verified the token by the time this runs.
this.logger.log(`Client connected: ${client.id}`);
}

handleDisconnect(client: Socket): void {
this.logger.log(`Client disconnected: ${client.id}`);
}

@UseGuards(WsAuthGuard)
@SubscribeMessage('join')
handleJoin(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() dto: { sessionId: string },
): void {
// Use verified user identity from token — NOT dto.userId (#796)
const userId = client.data.user.sub;
this.logger.log(`User ${userId} joining session ${dto.sessionId}`);
void client.join(dto.sessionId);
client.to(dto.sessionId).emit('user-joined', { userId, sessionId: dto.sessionId });
}

@UseGuards(WsAuthGuard)
@SubscribeMessage('operation')
handleOperation(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() dto: { sessionId: string; operation: unknown },
): void {
const userId = client.data.user.sub;
client.to(dto.sessionId).emit('operation', { userId, operation: dto.operation });
}
}
27 changes: 27 additions & 0 deletions src/common/decorators/match.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';

export function Match(property: string, validationOptions?: ValidationOptions) {
return (object: object, propertyName: string) => {
registerDecorator({
name: 'match',
target: (object as { constructor: Function }).constructor,
propertyName,
constraints: [property],
options: {
message: `${propertyName} must match ${property}`,
...validationOptions,
},
validator: {
validate(value: unknown, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints as string[];
const relatedValue = (args.object as Record<string, unknown>)[relatedPropertyName];
return value === relatedValue;
},
},
});
};
}
Loading
Loading