backend: enhance token validation with new error handling and metadata parsing for participants and recordings

This commit is contained in:
juancarmore 2025-08-13 17:21:09 +02:00
parent 972f6f4f90
commit 94b98600ba
7 changed files with 132 additions and 51 deletions

View File

@ -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) => {

View File

@ -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();
};

View File

@ -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();

View File

@ -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<ParticipantOptions> = z.object({
participantName: z.string().optional()
});
const UpdateParticipantRequestSchema = z.object({
role: z.enum([ParticipantRole.MODERATOR, ParticipantRole.SPEAKER])
});
const OpenViduMeetPermissionsSchema: z.ZodType<OpenViduMeetPermissions> = z.object({
canRecord: z.boolean(),
canChat: z.boolean(),
canChangeVirtualBackground: z.boolean()
});
const MeetTokenMetadataSchema: z.ZodType<MeetTokenMetadata> = 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;
};

View File

@ -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<RecordingPermissions> = 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;
};

View File

@ -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
);
};

View File

@ -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
*/