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'; import ms from 'ms'; import { container } from '../config/index.js'; import INTERNAL_CONFIG from '../config/internal-config.js'; import { errorInsufficientPermissions, errorInvalidApiKey, errorInvalidParticipantRole, errorInvalidParticipantToken, errorInvalidRecordingToken, errorInvalidToken, errorInvalidTokenSubject, errorUnauthorized, internalError, OpenViduMeetError, rejectRequestFromMeetError } from '../models/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. * If any of the validators grants access, the request is allowed to continue, skipping the rest of the validators. * If none of the validators grants access, the request is rejected with an unauthorized error. * * @param validators List of validators to check if the request is authorized * @returns RequestHandler middleware */ export const withAuth = (...validators: ((req: Request) => Promise)[]): RequestHandler => { return async (req: Request, res: Response, next: NextFunction) => { let lastError: OpenViduMeetError | null = null; for (const validator of validators) { try { await validator(req); // If any middleware granted access, it is not necessary to continue checking the rest return next(); } catch (error) { if (isErrorWithControl(error)) { lastError = error.error; if (error.stopValidation) { // Stop checking other validators break; } } } } if (lastError) { return rejectRequestFromMeetError(res, lastError); } const error = internalError('authenticating user'); return rejectRequestFromMeetError(res, error); }; }; // Configure token validatior for role-based access export const tokenAndRoleValidator = (role: UserRole) => { return async (req: Request) => { const token = req.cookies[INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME]; if (!token) { throw errorWithControl(errorUnauthorized(), false); } const tokenService = container.get(TokenService); let payload: ClaimGrants; try { payload = await tokenService.verifyToken(token); } catch (error) { throw errorWithControl(errorInvalidToken(), true); } const username = payload.sub; const userService = container.get(UserService); const user = username ? await userService.getUser(username) : null; if (!user) { throw errorWithControl(errorInvalidTokenSubject(), true); } if (!user.roles.includes(role)) { throw errorWithControl(errorInsufficientPermissions(), false); } req.session = req.session || {}; req.session.user = user; }; }; // Configure token validator for participant access export const participantTokenValidator = async (req: Request) => { await validateTokenAndSetSession(req, INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME); // Check if the participant role is provided in the request headers // This is required to distinguish roles when multiple are present in the token const participantRole = req.headers[INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER]; const allRoles = [ParticipantRole.MODERATOR, ParticipantRole.SPEAKER]; 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); } const roles = metadata.roles; const hasRole = roles.some( (r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === participantRole ); if (!hasRole) { throw errorWithControl(errorInsufficientPermissions(), true); } // Set the participant role in the session req.session!.participantRole = participantRole as ParticipantRole; }; // 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) => { const token = req.cookies[cookieName]; if (!token) { throw errorWithControl(errorUnauthorized(), false); } const tokenService = container.get(TokenService); let payload: ClaimGrants; try { payload = await tokenService.verifyToken(token); const user = await getAuthenticatedUserOrAnonymous(req); req.session = req.session || {}; req.session.tokenClaims = payload; req.session.user = user; } catch (error) { throw errorWithControl(errorInvalidToken(), true); } }; // Configure API key validatior export const apiKeyValidator = async (req: Request) => { const apiKey = req.headers[INTERNAL_CONFIG.API_KEY_HEADER]; if (!apiKey) { throw errorWithControl(errorUnauthorized(), false); } try { const authService = container.get(AuthService); const isValidApiKey = await authService.validateApiKey(apiKey as string); if (!isValidApiKey) { throw errorInvalidApiKey(); } } catch (error) { if (error instanceof OpenViduMeetError) { throw errorWithControl(error, true); } else { const logger = container.get(LoggerService); logger.error('Error validating API key:', error); throw errorWithControl(internalError('validating API key'), true); } } const userService = container.get(UserService); const apiUser = userService.getApiUser(); req.session = req.session || {}; req.session.user = apiUser; }; // Allow anonymous access export const allowAnonymous = async (req: Request) => { const user = await getAuthenticatedUserOrAnonymous(req); req.session = req.session || {}; req.session.user = user; }; // Return the authenticated user if available, otherwise return an anonymous user const getAuthenticatedUserOrAnonymous = async (req: Request): Promise => { const userService = container.get(UserService); let user: User | null = null; // Check if there is a user already authenticated const token = req.cookies[INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME]; if (token) { try { const tokenService = container.get(TokenService); const payload = await tokenService.verifyToken(token); const username = payload.sub; user = username ? await userService.getUser(username) : null; } catch (error) { const logger = container.get(LoggerService); logger.debug('Token found but invalid:' + error); } } if (!user) { user = userService.getAnonymousUser(); } return user; }; // Limit login attempts to avoid brute force attacks const loginLimiter = rateLimit({ windowMs: ms('5m'), limit: 5, skipSuccessfulRequests: true, message: 'Too many login attempts, please try again later' }); export const withLoginLimiter = (req: Request, res: Response, next: NextFunction) => { // Bypass rate limiting in test environment if (process.env.NODE_ENV === 'test') { return next(); } return loginLimiter(req, res, next); }; // OpenViduMeetError with control to stop checking other validators interface ErrorWithControl { error: OpenViduMeetError; stopValidation: boolean; } const errorWithControl = (error: OpenViduMeetError, stopValidation: boolean): ErrorWithControl => { const errorWithControl: ErrorWithControl = { error, stopValidation }; return errorWithControl; }; const isErrorWithControl = (error: unknown): error is ErrorWithControl => { return typeof error === 'object' && error !== null && 'error' in error && 'stopValidation' in error; };