From 22ce0e7d66b6a5040d647b07c03ab5df8298b118 Mon Sep 17 00:00:00 2001 From: juancarmore Date: Fri, 11 Jul 2025 01:41:44 +0200 Subject: [PATCH] backend: upgrade participant token generation to include multiple roles in the same token --- backend/package-lock.json | 10 ++ backend/package.json | 1 + .../src/controllers/participant.controller.ts | 96 +++++++++++++------ backend/src/controllers/room.controller.ts | 6 +- backend/src/models/error.model.ts | 12 +++ backend/src/services/participant.service.ts | 38 +++++--- backend/src/services/token.service.ts | 32 +++++-- backend/src/utils/cookie-utils.ts | 4 +- 8 files changed, 144 insertions(+), 55 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index e62e2e9..372bfc6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,6 +24,7 @@ "express-rate-limit": "^7.5.0", "inversify": "^6.2.1", "ioredis": "^5.4.2", + "jwt-decode": "4.0.0", "livekit-server-sdk": "2.6.2", "ms": "2.1.3", "uid": "^2.0.2", @@ -11261,6 +11262,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/backend/package.json b/backend/package.json index 6014ec1..6222b27 100644 --- a/backend/package.json +++ b/backend/package.json @@ -66,6 +66,7 @@ "express-rate-limit": "^7.5.0", "inversify": "^6.2.1", "ioredis": "^5.4.2", + "jwt-decode": "4.0.0", "livekit-server-sdk": "2.6.2", "ms": "2.1.3", "uid": "^2.0.2", diff --git a/backend/src/controllers/participant.controller.ts b/backend/src/controllers/participant.controller.ts index 12523d0..41e77c0 100644 --- a/backend/src/controllers/participant.controller.ts +++ b/backend/src/controllers/participant.controller.ts @@ -1,9 +1,14 @@ -import { ParticipantOptions } from '@typings-ce'; +import { OpenViduMeetPermissions, ParticipantOptions, ParticipantRole } from '@typings-ce'; import { Request, Response } from 'express'; import { container } from '../config/index.js'; import INTERNAL_CONFIG from '../config/internal-config.js'; -import { MEET_PARTICIPANT_TOKEN_EXPIRATION } from '../environment.js'; -import { errorParticipantTokenStillValid, handleError, rejectRequestFromMeetError } from '../models/error.model.js'; +import { + errorInvalidParticipantToken, + errorParticipantTokenNotPresent, + errorParticipantTokenStillValid, + handleError, + rejectRequestFromMeetError +} from '../models/error.model.js'; import { LoggerService, ParticipantService, RoomService, TokenService } from '../services/index.js'; import { getCookieOptions } from '../utils/cookie-utils.js'; @@ -14,16 +19,31 @@ export const generateParticipantToken = async (req: Request, res: Response) => { const participantOptions: ParticipantOptions = req.body; const { roomId } = participantOptions; + // Check if there is a previous token + const previousToken = req.cookies[INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME]; + let currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] = []; + + if (previousToken) { + // If there is a previous token, extract the roles from it + // and use them to generate the new token, aggregating the new role to the current ones + logger.verbose('Previous participant token found. Extracting roles'); + const tokenService = container.get(TokenService); + + try { + const claims = tokenService.getClaimsIgnoringExpiration(previousToken); + const metadata = JSON.parse(claims.metadata || '{}'); + currentRoles = metadata.roles; + } catch (error) { + logger.verbose('Error extracting roles from previous token:', error); + } + } + try { logger.verbose(`Generating participant token for room '${roomId}'`); await roomService.createLivekitRoom(roomId); - const token = await participantService.generateOrRefreshParticipantToken(participantOptions); + const token = await participantService.generateOrRefreshParticipantToken(participantOptions, currentRoles); - res.cookie( - INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME, - token, - getCookieOptions('/', MEET_PARTICIPANT_TOKEN_EXPIRATION) - ); + res.cookie(INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/')); return res.status(200).json({ token }); } catch (error) { handleError(res, error, `generating participant token for room '${roomId}'`); @@ -33,22 +53,39 @@ export const generateParticipantToken = async (req: Request, res: Response) => { export const refreshParticipantToken = async (req: Request, res: Response) => { const logger = container.get(LoggerService); - // Check if there is a previous token and if it is valid + // Check if there is a previous token and if it is expired const previousToken = req.cookies[INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME]; - if (previousToken) { - logger.verbose('Previous participant token found. Checking validity'); - const tokenService = container.get(TokenService); + // If there is no previous token, we cannot refresh it + if (!previousToken) { + logger.verbose('No previous participant token found. Cannot refresh.'); + const error = errorParticipantTokenNotPresent(); + return rejectRequestFromMeetError(res, error); + } - try { - await tokenService.verifyToken(previousToken); - logger.verbose('Previous participant token is valid. No need to refresh'); + const tokenService = container.get(TokenService); - const error = errorParticipantTokenStillValid(); - return rejectRequestFromMeetError(res, error); - } catch (error) { - logger.verbose('Previous participant token is invalid'); - } + // If the previous token is still valid, we do not need to refresh it + try { + await tokenService.verifyToken(previousToken); + logger.verbose('Previous participant token is valid. No need to refresh'); + const error = errorParticipantTokenStillValid(); + return rejectRequestFromMeetError(res, error); + } catch (error) { + // Previous token is expired, we can proceed to refresh it + } + + // Extract roles from the previous token + let currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] = []; + + try { + const claims = tokenService.getClaimsIgnoringExpiration(previousToken); + const metadata = JSON.parse(claims.metadata || '{}'); + currentRoles = metadata.roles; + } catch (err) { + logger.verbose('Error extracting roles from previous token:', err); + const error = errorInvalidParticipantToken(); + return rejectRequestFromMeetError(res, error); } const participantOptions: ParticipantOptions = req.body; @@ -56,15 +93,14 @@ export const refreshParticipantToken = async (req: Request, res: Response) => { const participantService = container.get(ParticipantService); try { - logger.verbose(`Refreshing participant token for room ${roomId}`); - const token = await participantService.generateOrRefreshParticipantToken(participantOptions, true); - - res.cookie( - INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME, - token, - getCookieOptions('/', MEET_PARTICIPANT_TOKEN_EXPIRATION) + logger.verbose(`Refreshing participant token for room '${roomId}'`); + const token = await participantService.generateOrRefreshParticipantToken( + participantOptions, + currentRoles, + true ); - logger.verbose(`Participant token refreshed for room '${roomId}'`); + + res.cookie(INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/')); return res.status(200).json({ token }); } catch (error) { handleError(res, error, `refreshing participant token for room '${roomId}'`); @@ -86,7 +122,7 @@ export const deleteParticipant = async (req: Request, res: Response) => { try { logger.verbose(`Deleting participant '${participantName}' from room '${roomId}'`); - await participantService.deleteParticipant(participantName, roomId); + await participantService.deleteParticipant(roomId, participantName); res.status(200).json({ message: 'Participant deleted' }); } catch (error) { handleError(res, error, `deleting participant '${participantName}' from room '${roomId}'`); diff --git a/backend/src/controllers/room.controller.ts b/backend/src/controllers/room.controller.ts index c043fdc..9e19f05 100644 --- a/backend/src/controllers/room.controller.ts +++ b/backend/src/controllers/room.controller.ts @@ -172,8 +172,8 @@ export const getRoomRolesAndPermissions = async (req: Request, res: Response) => } logger.verbose(`Getting roles and associated permissions for room '${roomId}'`); - const moderatorPermissions = participantService.getParticipantPermissions(ParticipantRole.MODERATOR, roomId); - const publisherPermissions = participantService.getParticipantPermissions(ParticipantRole.PUBLISHER, roomId); + const moderatorPermissions = participantService.getParticipantPermissions(roomId, ParticipantRole.MODERATOR); + const publisherPermissions = participantService.getParticipantPermissions(roomId, ParticipantRole.PUBLISHER); const rolesAndPermissions = [ { @@ -199,7 +199,7 @@ export const getRoomRoleAndPermissions = async (req: Request, res: Response) => logger.verbose(`Getting room role and associated permissions for room '${roomId}' and secret '${secret}'`); const role = await roomService.getRoomRoleBySecret(roomId, secret); - const permissions = participantService.getParticipantPermissions(role, roomId); + const permissions = participantService.getParticipantPermissions(roomId, role); const roleAndPermissions: MeetRoomRoleAndPermissions = { role, permissions diff --git a/backend/src/models/error.model.ts b/backend/src/models/error.model.ts index 89c6bda..8a3939f 100644 --- a/backend/src/models/error.model.ts +++ b/backend/src/models/error.model.ts @@ -230,6 +230,18 @@ export const errorParticipantTokenStillValid = (): OpenViduMeetError => { return new OpenViduMeetError('Participant Error', 'Participant token is still valid', 409); }; +export const errorParticipantTokenNotPresent = (): OpenViduMeetError => { + return new OpenViduMeetError('Participant', 'No participant token provided', 400); +}; + +export const errorInvalidParticipantToken = (): OpenViduMeetError => { + return new OpenViduMeetError('Participant', 'Invalid participant token', 400); +}; + +export const errorInvalidParticipantRole = (): OpenViduMeetError => { + return new OpenViduMeetError('Participant', 'No valid participant role provided', 400); +}; + // Handlers export const handleError = (res: Response, error: OpenViduMeetError | unknown, operationDescription: string) => { diff --git a/backend/src/services/participant.service.ts b/backend/src/services/participant.service.ts index 3d3d3eb..f119281 100644 --- a/backend/src/services/participant.service.ts +++ b/backend/src/services/participant.service.ts @@ -1,4 +1,4 @@ -import { ParticipantOptions, ParticipantPermissions, ParticipantRole } from '@typings-ce'; +import { OpenViduMeetPermissions, ParticipantOptions, ParticipantPermissions, ParticipantRole } from '@typings-ce'; import { inject, injectable } from 'inversify'; import { ParticipantInfo } from 'livekit-server-sdk'; import { errorParticipantAlreadyExists, errorParticipantNotFound } from '../models/error.model.js'; @@ -13,43 +13,53 @@ export class ParticipantService { @inject(TokenService) protected tokenService: TokenService ) {} - async generateOrRefreshParticipantToken(participantOptions: ParticipantOptions, refresh = false): Promise { + async generateOrRefreshParticipantToken( + participantOptions: ParticipantOptions, + currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[], + refresh = false + ): Promise { const { roomId, participantName, secret } = participantOptions; // Check if participant with same participantName exists in the room const participantExists = await this.participantExists(roomId, participantName); if (!refresh && participantExists) { - this.logger.verbose(`Participant ${participantName} already exists in room ${roomId}`); + this.logger.verbose(`Participant '${participantName}' already exists in room '${roomId}'`); throw errorParticipantAlreadyExists(participantName, roomId); } if (refresh && !participantExists) { - this.logger.verbose(`Participant ${participantName} does not exist in room ${roomId}`); + this.logger.verbose(`Participant '${participantName}' does not exist in room '${roomId}'`); throw errorParticipantNotFound(participantName, roomId); } const role = await this.roomService.getRoomRoleBySecret(roomId, secret); - const token = await this.generateParticipantToken(role, participantOptions); - this.logger.verbose(`Participant token generated for room ${roomId}`); + const token = await this.generateParticipantToken(participantOptions, role, currentRoles); + this.logger.verbose(`Participant token generated for room '${roomId}'`); return token; } protected async generateParticipantToken( + participantOptions: ParticipantOptions, role: ParticipantRole, - participantOptions: ParticipantOptions + currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] ): Promise { - const permissions = this.getParticipantPermissions(role, participantOptions.roomId); - return this.tokenService.generateParticipantToken(participantOptions, permissions, role); + const permissions = this.getParticipantPermissions(participantOptions.roomId, role); + + if (!currentRoles.some((r) => r.role === role)) { + currentRoles.push({ role, permissions: permissions.openvidu }); + } + + return this.tokenService.generateParticipantToken(participantOptions, permissions.livekit, currentRoles); } async getParticipant(roomId: string, participantName: string): Promise { - this.logger.verbose(`Fetching participant ${participantName}`); + this.logger.verbose(`Fetching participant '${participantName}'`); return this.livekitService.getParticipant(roomId, participantName); } async participantExists(roomId: string, participantName: string): Promise { - this.logger.verbose(`Checking if participant ${participantName} exists in room ${roomId}`); + this.logger.verbose(`Checking if participant '${participantName}' exists in room '${roomId}'`); try { const participant = await this.getParticipant(roomId, participantName); @@ -59,13 +69,13 @@ export class ParticipantService { } } - async deleteParticipant(participantName: string, roomId: string): Promise { - this.logger.verbose(`Deleting participant ${participantName} from room ${roomId}`); + async deleteParticipant(roomId: string, participantName: string): Promise { + this.logger.verbose(`Deleting participant '${participantName}' from room '${roomId}'`); return this.livekitService.deleteParticipant(participantName, roomId); } - getParticipantPermissions(role: ParticipantRole, roomId: string): ParticipantPermissions { + getParticipantPermissions(roomId: string, role: ParticipantRole): ParticipantPermissions { switch (role) { case ParticipantRole.MODERATOR: return this.generateModeratorPermissions(roomId); diff --git a/backend/src/services/token.service.ts b/backend/src/services/token.service.ts index b851c8f..b356746 100644 --- a/backend/src/services/token.service.ts +++ b/backend/src/services/token.service.ts @@ -1,4 +1,11 @@ -import { ParticipantOptions, ParticipantPermissions, ParticipantRole, RecordingPermissions, User } from '@typings-ce'; +import { + LiveKitPermissions, + OpenViduMeetPermissions, + ParticipantOptions, + ParticipantRole, + RecordingPermissions, + User +} from '@typings-ce'; import { inject, injectable } from 'inversify'; import { AccessToken, AccessTokenOptions, ClaimGrants, TokenVerifier, VideoGrant } from 'livekit-server-sdk'; import { @@ -11,6 +18,7 @@ import { MEET_REFRESH_TOKEN_EXPIRATION } from '../environment.js'; import { LoggerService } from './index.js'; +import { jwtDecode } from 'jwt-decode'; @injectable() export class TokenService { @@ -40,8 +48,8 @@ export class TokenService { async generateParticipantToken( participantOptions: ParticipantOptions, - permissions: ParticipantPermissions, - role: ParticipantRole + lkPermissions: LiveKitPermissions, + roles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] ): Promise { const { roomId, participantName } = participantOptions; this.logger.info(`Generating token for ${participantName} in room ${roomId}`); @@ -52,11 +60,10 @@ export class TokenService { ttl: MEET_PARTICIPANT_TOKEN_EXPIRATION, metadata: JSON.stringify({ livekitUrl: LIVEKIT_URL, - role, - permissions: permissions.openvidu + roles }) }; - return await this.generateJwtToken(tokenOptions, permissions.livekit as VideoGrant); + return await this.generateJwtToken(tokenOptions, lkPermissions as VideoGrant); } async generateRecordingToken( @@ -92,4 +99,17 @@ export class TokenService { const verifyer = new TokenVerifier(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); return await verifyer.verify(token); } + + /** + * Decodes a JWT and returns its ClaimGrants, even if expired. + */ + getClaimsIgnoringExpiration(token: string): ClaimGrants { + try { + const decoded = jwtDecode(token); + return decoded; + } catch (error) { + this.logger.error('Failed to decode JWT:', error); + throw error; + } + } } diff --git a/backend/src/utils/cookie-utils.ts b/backend/src/utils/cookie-utils.ts index 634c162..9c1f0e1 100644 --- a/backend/src/utils/cookie-utils.ts +++ b/backend/src/utils/cookie-utils.ts @@ -2,12 +2,12 @@ import { CookieOptions } from 'express'; import ms, { StringValue } from 'ms'; import { MEET_COOKIE_SECURE } from '../environment.js'; -export const getCookieOptions = (path: string, expiration: string): CookieOptions => { +export const getCookieOptions = (path: string, expiration?: string): CookieOptions => { return { httpOnly: true, secure: MEET_COOKIE_SECURE === 'true', sameSite: 'strict', - maxAge: ms(expiration as StringValue), + maxAge: expiration ? ms(expiration as StringValue) : undefined, path }; };