backend: enhance token validation with new error handling and metadata parsing for participants and recordings
This commit is contained in:
parent
972f6f4f90
commit
94b98600ba
@ -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 { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import { ClaimGrants } from 'livekit-server-sdk';
|
import { ClaimGrants } from 'livekit-server-sdk';
|
||||||
@ -9,6 +9,8 @@ import {
|
|||||||
errorInsufficientPermissions,
|
errorInsufficientPermissions,
|
||||||
errorInvalidApiKey,
|
errorInvalidApiKey,
|
||||||
errorInvalidParticipantRole,
|
errorInvalidParticipantRole,
|
||||||
|
errorInvalidParticipantToken,
|
||||||
|
errorInvalidRecordingToken,
|
||||||
errorInvalidToken,
|
errorInvalidToken,
|
||||||
errorInvalidTokenSubject,
|
errorInvalidTokenSubject,
|
||||||
errorUnauthorized,
|
errorUnauthorized,
|
||||||
@ -16,7 +18,14 @@ import {
|
|||||||
OpenViduMeetError,
|
OpenViduMeetError,
|
||||||
rejectRequestFromMeetError
|
rejectRequestFromMeetError
|
||||||
} from '../models/index.js';
|
} 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.
|
* 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);
|
throw errorWithControl(errorInvalidParticipantRole(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!participantRole || !allRoles.includes(participantRole as ParticipantRole)) {
|
// Check that the specified role is present in the token claims
|
||||||
throw errorWithControl(errorInvalidParticipantRole(), true);
|
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 roles = metadata.roles;
|
||||||
const metadata = JSON.parse(req.session?.tokenClaims?.metadata || '{}');
|
|
||||||
const roles = metadata.roles || [];
|
|
||||||
const hasRole = roles.some(
|
const hasRole = roles.some(
|
||||||
(r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === participantRole
|
(r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === participantRole
|
||||||
);
|
);
|
||||||
@ -126,6 +141,16 @@ export const participantTokenValidator = async (req: Request) => {
|
|||||||
// Configure token validator for recording access
|
// Configure token validator for recording access
|
||||||
export const recordingTokenValidator = async (req: Request) => {
|
export const recordingTokenValidator = async (req: Request) => {
|
||||||
await validateTokenAndSetSession(req, INTERNAL_CONFIG.RECORDING_TOKEN_COOKIE_NAME);
|
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) => {
|
const validateTokenAndSetSession = async (req: Request, cookieName: string) => {
|
||||||
|
|||||||
@ -1,12 +1,7 @@
|
|||||||
import { AuthMode, ParticipantOptions, ParticipantRole, UserRole } from '@typings-ce';
|
import { AuthMode, ParticipantOptions, ParticipantRole, UserRole } from '@typings-ce';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import { container } from '../config/index.js';
|
import { container } from '../config/index.js';
|
||||||
import {
|
import { errorInsufficientPermissions, handleError, rejectRequestFromMeetError } from '../models/error.model.js';
|
||||||
errorInsufficientPermissions,
|
|
||||||
errorInvalidParticipantRole,
|
|
||||||
handleError,
|
|
||||||
rejectRequestFromMeetError
|
|
||||||
} from '../models/error.model.js';
|
|
||||||
import { MeetStorageService, RoomService } from '../services/index.js';
|
import { MeetStorageService, RoomService } from '../services/index.js';
|
||||||
import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middleware.js';
|
import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middleware.js';
|
||||||
|
|
||||||
@ -96,22 +91,3 @@ export const checkParticipantFromSameRoom = async (req: Request, res: Response,
|
|||||||
|
|
||||||
return next();
|
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();
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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 { NextFunction, Request, Response } from 'express';
|
||||||
import { container } from '../config/index.js';
|
import { container } from '../config/index.js';
|
||||||
import { RecordingHelper } from '../helpers/index.js';
|
import { RecordingHelper } from '../helpers/index.js';
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
handleError,
|
handleError,
|
||||||
rejectRequestFromMeetError
|
rejectRequestFromMeetError
|
||||||
} from '../models/error.model.js';
|
} from '../models/error.model.js';
|
||||||
import { LoggerService, MeetStorageService, RoomService } from '../services/index.js';
|
import { LoggerService, MeetStorageService, ParticipantService, RoomService } from '../services/index.js';
|
||||||
import {
|
import {
|
||||||
allowAnonymous,
|
allowAnonymous,
|
||||||
apiKeyValidator,
|
apiKeyValidator,
|
||||||
@ -49,11 +49,11 @@ export const withCanRecordPermission = async (req: Request, res: Response, next:
|
|||||||
return rejectRequestFromMeetError(res, error);
|
return rejectRequestFromMeetError(res, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const participantService = container.get(ParticipantService);
|
||||||
|
const metadata = participantService.parseMetadata(payload.metadata || '{}');
|
||||||
|
|
||||||
const sameRoom = payload.video?.room === roomId;
|
const sameRoom = payload.video?.room === roomId;
|
||||||
const metadata = JSON.parse(payload.metadata || '{}');
|
const permissions = metadata.roles.find((r) => r.role === role)?.permissions;
|
||||||
const permissions = metadata.roles?.find(
|
|
||||||
(r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === role
|
|
||||||
)?.permissions as OpenViduMeetPermissions | undefined;
|
|
||||||
const canRecord = permissions?.canRecord;
|
const canRecord = permissions?.canRecord;
|
||||||
|
|
||||||
if (!sameRoom || !canRecord) {
|
if (!sameRoom || !canRecord) {
|
||||||
@ -80,10 +80,11 @@ export const withCanRetrieveRecordingsPermission = async (req: Request, res: Res
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roomService = container.get(RoomService);
|
||||||
|
const metadata = roomService.parseRecordingTokenMetadata(payload.metadata || '{}');
|
||||||
|
|
||||||
const sameRoom = roomId ? payload.video?.room === roomId : true;
|
const sameRoom = roomId ? payload.video?.room === roomId : true;
|
||||||
const metadata = JSON.parse(payload.metadata || '{}');
|
const canRetrieveRecordings = metadata.recordingPermissions.canRetrieveRecordings;
|
||||||
const permissions = metadata.recordingPermissions as RecordingPermissions | undefined;
|
|
||||||
const canRetrieveRecordings = permissions?.canRetrieveRecordings;
|
|
||||||
|
|
||||||
if (!sameRoom || !canRetrieveRecordings) {
|
if (!sameRoom || !canRetrieveRecordings) {
|
||||||
const error = errorInsufficientPermissions();
|
const error = errorInsufficientPermissions();
|
||||||
@ -103,10 +104,11 @@ export const withCanDeleteRecordingsPermission = async (req: Request, res: Respo
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roomService = container.get(RoomService);
|
||||||
|
const metadata = roomService.parseRecordingTokenMetadata(payload.metadata || '{}');
|
||||||
|
|
||||||
const sameRoom = roomId ? payload.video?.room === roomId : true;
|
const sameRoom = roomId ? payload.video?.room === roomId : true;
|
||||||
const metadata = JSON.parse(payload.metadata || '{}');
|
const canDeleteRecordings = metadata.recordingPermissions.canDeleteRecordings;
|
||||||
const permissions = metadata.recordingPermissions as RecordingPermissions | undefined;
|
|
||||||
const canDeleteRecordings = permissions?.canDeleteRecordings;
|
|
||||||
|
|
||||||
if (!sameRoom || !canDeleteRecordings) {
|
if (!sameRoom || !canDeleteRecordings) {
|
||||||
const error = errorInsufficientPermissions();
|
const error = errorInsufficientPermissions();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ParticipantOptions } from '@typings-ce';
|
import { MeetTokenMetadata, OpenViduMeetPermissions, ParticipantOptions, ParticipantRole } from '@typings-ce';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { rejectUnprocessableRequest } from '../../models/error.model.js';
|
import { rejectUnprocessableRequest } from '../../models/error.model.js';
|
||||||
@ -10,6 +10,27 @@ const ParticipantTokenRequestSchema: z.ZodType<ParticipantOptions> = z.object({
|
|||||||
participantName: z.string().optional()
|
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) => {
|
export const validateParticipantTokenRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||||
const { success, error, data } = ParticipantTokenRequestSchema.safeParse(req.body);
|
const { success, error, data } = ParticipantTokenRequestSchema.safeParse(req.body);
|
||||||
|
|
||||||
@ -20,3 +41,24 @@ export const validateParticipantTokenRequest = (req: Request, res: Response, nex
|
|||||||
req.body = data;
|
req.body = data;
|
||||||
next();
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import {
|
|||||||
MeetRoomFilters,
|
MeetRoomFilters,
|
||||||
MeetRoomOptions,
|
MeetRoomOptions,
|
||||||
MeetRoomPreferences,
|
MeetRoomPreferences,
|
||||||
MeetVirtualBackgroundPreferences
|
MeetVirtualBackgroundPreferences,
|
||||||
|
ParticipantRole,
|
||||||
|
RecordingPermissions
|
||||||
} from '@typings-ce';
|
} from '@typings-ce';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
@ -185,6 +187,16 @@ const RecordingTokenRequestSchema = z.object({
|
|||||||
secret: z.string().nonempty('Secret is required')
|
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) => {
|
export const withValidRoomOptions = (req: Request, res: Response, next: NextFunction) => {
|
||||||
const { success, error, data } = RoomRequestOptionsSchema.safeParse(req.body);
|
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);
|
return rejectUnprocessableRequest(res, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('VALID ROOM OPTIONS', data);
|
|
||||||
req.body = data;
|
req.body = data;
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
@ -275,3 +286,13 @@ export const withValidRoomSecret = (req: Request, res: Response, next: NextFunct
|
|||||||
req.body = data;
|
req.body = data;
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const validateRecordingTokenMetadata = (metadata: unknown) => {
|
||||||
|
const { success, error, data } = RecordingTokenMetadataSchema.safeParse(metadata);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new Error(`Invalid metadata: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|||||||
@ -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 => {
|
const isMatchingError = (error: OpenViduMeetError, originalError: OpenViduMeetError): boolean => {
|
||||||
return (
|
return (
|
||||||
error instanceof OpenViduMeetError &&
|
error instanceof OpenViduMeetError &&
|
||||||
@ -210,18 +214,18 @@ export const errorInvalidRoomSecret = (roomId: string, secret: string): OpenVidu
|
|||||||
|
|
||||||
// Participant errors
|
// Participant errors
|
||||||
|
|
||||||
export const errorParticipantNotFound = (participantName: string, roomId: string): OpenViduMeetError => {
|
export const errorParticipantNotFound = (participantIdentity: string, roomId: string): OpenViduMeetError => {
|
||||||
return new OpenViduMeetError(
|
return new OpenViduMeetError(
|
||||||
'Participant Error',
|
'Participant Error',
|
||||||
`Participant '${participantName}' not found in room '${roomId}'`,
|
`Participant '${participantIdentity}' not found in room '${roomId}'`,
|
||||||
404
|
404
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const errorParticipantAlreadyExists = (participantName: string, roomId: string): OpenViduMeetError => {
|
export const errorParticipantAlreadyExists = (participantIdentity: string, roomId: string): OpenViduMeetError => {
|
||||||
return new OpenViduMeetError(
|
return new OpenViduMeetError(
|
||||||
'Participant Error',
|
'Participant Error',
|
||||||
`Participant '${participantName}' already exists in room '${roomId}'`,
|
`Participant '${participantIdentity}' already exists in room '${roomId}'`,
|
||||||
409
|
409
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import {
|
|||||||
TokenService,
|
TokenService,
|
||||||
FrontendEventService
|
FrontendEventService
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
import { validateRecordingTokenMetadata } from '../middlewares/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for managing OpenVidu Meet rooms.
|
* 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
|
* Classifies rooms into those that should be deleted immediately vs marked for deletion
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user