From 94b98600ba49de4a53d9fa3f964d551b9ae12b21 Mon Sep 17 00:00:00 2001 From: juancarmore Date: Wed, 13 Aug 2025 17:21:09 +0200 Subject: [PATCH] backend: enhance token validation with new error handling and metadata parsing for participants and recordings --- backend/src/middlewares/auth.middleware.ts | 39 +++++++++++++--- .../src/middlewares/participant.middleware.ts | 26 +---------- .../src/middlewares/recording.middleware.ts | 26 ++++++----- .../participant-validator.middleware.ts | 44 ++++++++++++++++++- .../room-validator.middleware.ts | 25 ++++++++++- backend/src/models/error.model.ts | 12 +++-- backend/src/services/room.service.ts | 11 +++++ 7 files changed, 132 insertions(+), 51 deletions(-) diff --git a/backend/src/middlewares/auth.middleware.ts b/backend/src/middlewares/auth.middleware.ts index 1cc3655..bf2a13e 100644 --- a/backend/src/middlewares/auth.middleware.ts +++ b/backend/src/middlewares/auth.middleware.ts @@ -1,4 +1,4 @@ -import { OpenViduMeetPermissions, ParticipantRole, User, UserRole } from '@typings-ce'; +import { MeetTokenMetadata, OpenViduMeetPermissions, ParticipantRole, User, UserRole } from '@typings-ce'; import { NextFunction, Request, RequestHandler, Response } from 'express'; import rateLimit from 'express-rate-limit'; import { ClaimGrants } from 'livekit-server-sdk'; @@ -9,6 +9,8 @@ import { errorInsufficientPermissions, errorInvalidApiKey, errorInvalidParticipantRole, + errorInvalidParticipantToken, + errorInvalidRecordingToken, errorInvalidToken, errorInvalidTokenSubject, errorUnauthorized, @@ -16,7 +18,14 @@ import { OpenViduMeetError, rejectRequestFromMeetError } from '../models/index.js'; -import { AuthService, LoggerService, TokenService, UserService } from '../services/index.js'; +import { + AuthService, + LoggerService, + ParticipantService, + RoomService, + TokenService, + UserService +} from '../services/index.js'; /** * This middleware allows to chain multiple validators to check if the request is authorized. @@ -104,13 +113,19 @@ export const participantTokenValidator = async (req: Request) => { throw errorWithControl(errorInvalidParticipantRole(), true); } - if (!participantRole || !allRoles.includes(participantRole as ParticipantRole)) { - throw errorWithControl(errorInvalidParticipantRole(), true); + // Check that the specified role is present in the token claims + let metadata: MeetTokenMetadata; + + try { + const participantService = container.get(ParticipantService); + metadata = participantService.parseMetadata(req.session?.tokenClaims?.metadata || '{}'); + } catch (error) { + const logger = container.get(LoggerService); + logger.error('Invalid participant token:', error); + throw errorWithControl(errorInvalidParticipantToken(), true); } - // Check that the specified role is present in the token claims - const metadata = JSON.parse(req.session?.tokenClaims?.metadata || '{}'); - const roles = metadata.roles || []; + const roles = metadata.roles; const hasRole = roles.some( (r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === participantRole ); @@ -126,6 +141,16 @@ export const participantTokenValidator = async (req: Request) => { // Configure token validator for recording access export const recordingTokenValidator = async (req: Request) => { await validateTokenAndSetSession(req, INTERNAL_CONFIG.RECORDING_TOKEN_COOKIE_NAME); + + // Validate the recording token metadata + try { + const roomService = container.get(RoomService); + roomService.parseRecordingTokenMetadata(req.session?.tokenClaims?.metadata || '{}'); + } catch (error) { + const logger = container.get(LoggerService); + logger.error('Invalid recording token:', error); + throw errorWithControl(errorInvalidRecordingToken(), true); + } }; const validateTokenAndSetSession = async (req: Request, cookieName: string) => { diff --git a/backend/src/middlewares/participant.middleware.ts b/backend/src/middlewares/participant.middleware.ts index 99b65d9..df31f87 100644 --- a/backend/src/middlewares/participant.middleware.ts +++ b/backend/src/middlewares/participant.middleware.ts @@ -1,12 +1,7 @@ import { AuthMode, ParticipantOptions, ParticipantRole, UserRole } from '@typings-ce'; import { NextFunction, Request, Response } from 'express'; import { container } from '../config/index.js'; -import { - errorInsufficientPermissions, - errorInvalidParticipantRole, - handleError, - rejectRequestFromMeetError -} from '../models/error.model.js'; +import { errorInsufficientPermissions, handleError, rejectRequestFromMeetError } from '../models/error.model.js'; import { MeetStorageService, RoomService } from '../services/index.js'; import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middleware.js'; @@ -96,22 +91,3 @@ export const checkParticipantFromSameRoom = async (req: Request, res: Response, return next(); }; - -export const withValidParticipantRole = async (req: Request, res: Response, next: NextFunction) => { - const { role } = req.body; - - if (!role) { - const error = errorInvalidParticipantRole(); - return rejectRequestFromMeetError(res, error); - } - - // Validate the role against the ParticipantRole enum - const isRoleValid = role === ParticipantRole.MODERATOR || role === ParticipantRole.SPEAKER; - - if (!isRoleValid) { - const error = errorInvalidParticipantRole(); - return rejectRequestFromMeetError(res, error); - } - - return next(); -}; diff --git a/backend/src/middlewares/recording.middleware.ts b/backend/src/middlewares/recording.middleware.ts index 6cb89d3..2449a3d 100644 --- a/backend/src/middlewares/recording.middleware.ts +++ b/backend/src/middlewares/recording.middleware.ts @@ -1,4 +1,4 @@ -import { MeetRoom, OpenViduMeetPermissions, ParticipantRole, RecordingPermissions, UserRole } from '@typings-ce'; +import { MeetRoom, UserRole } from '@typings-ce'; import { NextFunction, Request, Response } from 'express'; import { container } from '../config/index.js'; import { RecordingHelper } from '../helpers/index.js'; @@ -10,7 +10,7 @@ import { handleError, rejectRequestFromMeetError } from '../models/error.model.js'; -import { LoggerService, MeetStorageService, RoomService } from '../services/index.js'; +import { LoggerService, MeetStorageService, ParticipantService, RoomService } from '../services/index.js'; import { allowAnonymous, apiKeyValidator, @@ -49,11 +49,11 @@ export const withCanRecordPermission = async (req: Request, res: Response, next: return rejectRequestFromMeetError(res, error); } + const participantService = container.get(ParticipantService); + const metadata = participantService.parseMetadata(payload.metadata || '{}'); + const sameRoom = payload.video?.room === roomId; - const metadata = JSON.parse(payload.metadata || '{}'); - const permissions = metadata.roles?.find( - (r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === role - )?.permissions as OpenViduMeetPermissions | undefined; + const permissions = metadata.roles.find((r) => r.role === role)?.permissions; const canRecord = permissions?.canRecord; if (!sameRoom || !canRecord) { @@ -80,10 +80,11 @@ export const withCanRetrieveRecordingsPermission = async (req: Request, res: Res return next(); } + const roomService = container.get(RoomService); + const metadata = roomService.parseRecordingTokenMetadata(payload.metadata || '{}'); + const sameRoom = roomId ? payload.video?.room === roomId : true; - const metadata = JSON.parse(payload.metadata || '{}'); - const permissions = metadata.recordingPermissions as RecordingPermissions | undefined; - const canRetrieveRecordings = permissions?.canRetrieveRecordings; + const canRetrieveRecordings = metadata.recordingPermissions.canRetrieveRecordings; if (!sameRoom || !canRetrieveRecordings) { const error = errorInsufficientPermissions(); @@ -103,10 +104,11 @@ export const withCanDeleteRecordingsPermission = async (req: Request, res: Respo return next(); } + const roomService = container.get(RoomService); + const metadata = roomService.parseRecordingTokenMetadata(payload.metadata || '{}'); + const sameRoom = roomId ? payload.video?.room === roomId : true; - const metadata = JSON.parse(payload.metadata || '{}'); - const permissions = metadata.recordingPermissions as RecordingPermissions | undefined; - const canDeleteRecordings = permissions?.canDeleteRecordings; + const canDeleteRecordings = metadata.recordingPermissions.canDeleteRecordings; if (!sameRoom || !canDeleteRecordings) { const error = errorInsufficientPermissions(); diff --git a/backend/src/middlewares/request-validators/participant-validator.middleware.ts b/backend/src/middlewares/request-validators/participant-validator.middleware.ts index 52e5414..4274d10 100644 --- a/backend/src/middlewares/request-validators/participant-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/participant-validator.middleware.ts @@ -1,4 +1,4 @@ -import { ParticipantOptions } from '@typings-ce'; +import { MeetTokenMetadata, OpenViduMeetPermissions, ParticipantOptions, ParticipantRole } from '@typings-ce'; import { NextFunction, Request, Response } from 'express'; import { z } from 'zod'; import { rejectUnprocessableRequest } from '../../models/error.model.js'; @@ -10,6 +10,27 @@ const ParticipantTokenRequestSchema: z.ZodType = z.object({ participantName: z.string().optional() }); +const UpdateParticipantRequestSchema = z.object({ + role: z.enum([ParticipantRole.MODERATOR, ParticipantRole.SPEAKER]) +}); + +const OpenViduMeetPermissionsSchema: z.ZodType = z.object({ + canRecord: z.boolean(), + canChat: z.boolean(), + canChangeVirtualBackground: z.boolean() +}); + +const MeetTokenMetadataSchema: z.ZodType = z.object({ + livekitUrl: z.string().url('LiveKit URL must be a valid URL'), + roles: z.array( + z.object({ + role: z.enum([ParticipantRole.MODERATOR, ParticipantRole.SPEAKER]), + permissions: OpenViduMeetPermissionsSchema + }) + ), + selectedRole: z.enum([ParticipantRole.MODERATOR, ParticipantRole.SPEAKER]) +}); + export const validateParticipantTokenRequest = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = ParticipantTokenRequestSchema.safeParse(req.body); @@ -20,3 +41,24 @@ export const validateParticipantTokenRequest = (req: Request, res: Response, nex req.body = data; next(); }; + +export const validateUpdateParticipantRequest = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = UpdateParticipantRequestSchema.safeParse(req.body); + + if (!success) { + return rejectUnprocessableRequest(res, error); + } + + req.body = data; + next(); +}; + +export const validateMeetTokenMetadata = (metadata: unknown): MeetTokenMetadata => { + const { success, error, data } = MeetTokenMetadataSchema.safeParse(metadata); + + if (!success) { + throw new Error(`Invalid metadata: ${error.message}`); + } + + return data; +}; diff --git a/backend/src/middlewares/request-validators/room-validator.middleware.ts b/backend/src/middlewares/request-validators/room-validator.middleware.ts index a70e2f9..a3aa719 100644 --- a/backend/src/middlewares/request-validators/room-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/room-validator.middleware.ts @@ -5,7 +5,9 @@ import { MeetRoomFilters, MeetRoomOptions, MeetRoomPreferences, - MeetVirtualBackgroundPreferences + MeetVirtualBackgroundPreferences, + ParticipantRole, + RecordingPermissions } from '@typings-ce'; import { NextFunction, Request, Response } from 'express'; import ms from 'ms'; @@ -185,6 +187,16 @@ const RecordingTokenRequestSchema = z.object({ secret: z.string().nonempty('Secret is required') }); +const RecordingPermissionsSchema: z.ZodType = z.object({ + canRetrieveRecordings: z.boolean(), + canDeleteRecordings: z.boolean() +}); + +const RecordingTokenMetadataSchema = z.object({ + role: z.enum([ParticipantRole.MODERATOR, ParticipantRole.SPEAKER]), + recordingPermissions: RecordingPermissionsSchema +}); + export const withValidRoomOptions = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = RoomRequestOptionsSchema.safeParse(req.body); @@ -192,7 +204,6 @@ export const withValidRoomOptions = (req: Request, res: Response, next: NextFunc return rejectUnprocessableRequest(res, error); } - console.log('VALID ROOM OPTIONS', data); req.body = data; next(); }; @@ -275,3 +286,13 @@ export const withValidRoomSecret = (req: Request, res: Response, next: NextFunct req.body = data; next(); }; + +export const validateRecordingTokenMetadata = (metadata: unknown) => { + const { success, error, data } = RecordingTokenMetadataSchema.safeParse(metadata); + + if (!success) { + throw new Error(`Invalid metadata: ${error.message}`); + } + + return data; +}; diff --git a/backend/src/models/error.model.ts b/backend/src/models/error.model.ts index 8a3939f..f740192 100644 --- a/backend/src/models/error.model.ts +++ b/backend/src/models/error.model.ts @@ -166,6 +166,10 @@ export const errorRecordingsNotFromSameRoom = (roomId: string): OpenViduMeetErro ); }; +export const errorInvalidRecordingToken = (): OpenViduMeetError => { + return new OpenViduMeetError('Recording', 'Invalid recording token', 400); +}; + const isMatchingError = (error: OpenViduMeetError, originalError: OpenViduMeetError): boolean => { return ( error instanceof OpenViduMeetError && @@ -210,18 +214,18 @@ export const errorInvalidRoomSecret = (roomId: string, secret: string): OpenVidu // Participant errors -export const errorParticipantNotFound = (participantName: string, roomId: string): OpenViduMeetError => { +export const errorParticipantNotFound = (participantIdentity: string, roomId: string): OpenViduMeetError => { return new OpenViduMeetError( 'Participant Error', - `Participant '${participantName}' not found in room '${roomId}'`, + `Participant '${participantIdentity}' not found in room '${roomId}'`, 404 ); }; -export const errorParticipantAlreadyExists = (participantName: string, roomId: string): OpenViduMeetError => { +export const errorParticipantAlreadyExists = (participantIdentity: string, roomId: string): OpenViduMeetError => { return new OpenViduMeetError( 'Participant Error', - `Participant '${participantName}' already exists in room '${roomId}'`, + `Participant '${participantIdentity}' already exists in room '${roomId}'`, 409 ); }; diff --git a/backend/src/services/room.service.ts b/backend/src/services/room.service.ts index a87cbea..9894ba5 100644 --- a/backend/src/services/room.service.ts +++ b/backend/src/services/room.service.ts @@ -31,6 +31,7 @@ import { TokenService, FrontendEventService } from './index.js'; +import { validateRecordingTokenMetadata } from '../middlewares/index.js'; /** * Service for managing OpenVidu Meet rooms. @@ -311,6 +312,16 @@ export class RoomService { }; } + parseRecordingTokenMetadata(metadata: string) { + try { + const parsedMetadata = JSON.parse(metadata); + return validateRecordingTokenMetadata(parsedMetadata); + } catch (error) { + this.logger.error('Failed to parse recording token metadata:', error); + throw new Error('Invalid recording token metadata format'); + } + } + /** * Classifies rooms into those that should be deleted immediately vs marked for deletion */