backend: upgrade participant token generation to include multiple roles in the same token

This commit is contained in:
juancarmore 2025-07-11 01:41:44 +02:00
parent 166c3573d7
commit 22ce0e7d66
8 changed files with 144 additions and 55 deletions

View File

@ -24,6 +24,7 @@
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"inversify": "^6.2.1", "inversify": "^6.2.1",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"jwt-decode": "4.0.0",
"livekit-server-sdk": "2.6.2", "livekit-server-sdk": "2.6.2",
"ms": "2.1.3", "ms": "2.1.3",
"uid": "^2.0.2", "uid": "^2.0.2",
@ -11261,6 +11262,15 @@
"graceful-fs": "^4.1.6" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@ -66,6 +66,7 @@
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"inversify": "^6.2.1", "inversify": "^6.2.1",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"jwt-decode": "4.0.0",
"livekit-server-sdk": "2.6.2", "livekit-server-sdk": "2.6.2",
"ms": "2.1.3", "ms": "2.1.3",
"uid": "^2.0.2", "uid": "^2.0.2",

View File

@ -1,9 +1,14 @@
import { ParticipantOptions } from '@typings-ce'; import { OpenViduMeetPermissions, ParticipantOptions, ParticipantRole } from '@typings-ce';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { container } from '../config/index.js'; import { container } from '../config/index.js';
import INTERNAL_CONFIG from '../config/internal-config.js'; import INTERNAL_CONFIG from '../config/internal-config.js';
import { MEET_PARTICIPANT_TOKEN_EXPIRATION } from '../environment.js'; import {
import { errorParticipantTokenStillValid, handleError, rejectRequestFromMeetError } from '../models/error.model.js'; errorInvalidParticipantToken,
errorParticipantTokenNotPresent,
errorParticipantTokenStillValid,
handleError,
rejectRequestFromMeetError
} from '../models/error.model.js';
import { LoggerService, ParticipantService, RoomService, TokenService } from '../services/index.js'; import { LoggerService, ParticipantService, RoomService, TokenService } from '../services/index.js';
import { getCookieOptions } from '../utils/cookie-utils.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 participantOptions: ParticipantOptions = req.body;
const { roomId } = participantOptions; 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 { try {
logger.verbose(`Generating participant token for room '${roomId}'`); logger.verbose(`Generating participant token for room '${roomId}'`);
await roomService.createLivekitRoom(roomId); await roomService.createLivekitRoom(roomId);
const token = await participantService.generateOrRefreshParticipantToken(participantOptions); const token = await participantService.generateOrRefreshParticipantToken(participantOptions, currentRoles);
res.cookie( res.cookie(INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/'));
INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME,
token,
getCookieOptions('/', MEET_PARTICIPANT_TOKEN_EXPIRATION)
);
return res.status(200).json({ token }); return res.status(200).json({ token });
} catch (error) { } catch (error) {
handleError(res, error, `generating participant token for room '${roomId}'`); 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) => { export const refreshParticipantToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); 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]; const previousToken = req.cookies[INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME];
if (previousToken) { // If there is no previous token, we cannot refresh it
logger.verbose('Previous participant token found. Checking validity'); if (!previousToken) {
const tokenService = container.get(TokenService); logger.verbose('No previous participant token found. Cannot refresh.');
const error = errorParticipantTokenNotPresent();
return rejectRequestFromMeetError(res, error);
}
try { const tokenService = container.get(TokenService);
await tokenService.verifyToken(previousToken);
logger.verbose('Previous participant token is valid. No need to refresh');
const error = errorParticipantTokenStillValid(); // If the previous token is still valid, we do not need to refresh it
return rejectRequestFromMeetError(res, error); try {
} catch (error) { await tokenService.verifyToken(previousToken);
logger.verbose('Previous participant token is invalid'); 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; const participantOptions: ParticipantOptions = req.body;
@ -56,15 +93,14 @@ export const refreshParticipantToken = async (req: Request, res: Response) => {
const participantService = container.get(ParticipantService); const participantService = container.get(ParticipantService);
try { try {
logger.verbose(`Refreshing participant token for room ${roomId}`); logger.verbose(`Refreshing participant token for room '${roomId}'`);
const token = await participantService.generateOrRefreshParticipantToken(participantOptions, true); const token = await participantService.generateOrRefreshParticipantToken(
participantOptions,
res.cookie( currentRoles,
INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME, true
token,
getCookieOptions('/', MEET_PARTICIPANT_TOKEN_EXPIRATION)
); );
logger.verbose(`Participant token refreshed for room '${roomId}'`);
res.cookie(INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/'));
return res.status(200).json({ token }); return res.status(200).json({ token });
} catch (error) { } catch (error) {
handleError(res, error, `refreshing participant token for room '${roomId}'`); handleError(res, error, `refreshing participant token for room '${roomId}'`);
@ -86,7 +122,7 @@ export const deleteParticipant = async (req: Request, res: Response) => {
try { try {
logger.verbose(`Deleting participant '${participantName}' from room '${roomId}'`); 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' }); res.status(200).json({ message: 'Participant deleted' });
} catch (error) { } catch (error) {
handleError(res, error, `deleting participant '${participantName}' from room '${roomId}'`); handleError(res, error, `deleting participant '${participantName}' from room '${roomId}'`);

View File

@ -172,8 +172,8 @@ export const getRoomRolesAndPermissions = async (req: Request, res: Response) =>
} }
logger.verbose(`Getting roles and associated permissions for room '${roomId}'`); logger.verbose(`Getting roles and associated permissions for room '${roomId}'`);
const moderatorPermissions = participantService.getParticipantPermissions(ParticipantRole.MODERATOR, roomId); const moderatorPermissions = participantService.getParticipantPermissions(roomId, ParticipantRole.MODERATOR);
const publisherPermissions = participantService.getParticipantPermissions(ParticipantRole.PUBLISHER, roomId); const publisherPermissions = participantService.getParticipantPermissions(roomId, ParticipantRole.PUBLISHER);
const rolesAndPermissions = [ 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}'`); logger.verbose(`Getting room role and associated permissions for room '${roomId}' and secret '${secret}'`);
const role = await roomService.getRoomRoleBySecret(roomId, secret); const role = await roomService.getRoomRoleBySecret(roomId, secret);
const permissions = participantService.getParticipantPermissions(role, roomId); const permissions = participantService.getParticipantPermissions(roomId, role);
const roleAndPermissions: MeetRoomRoleAndPermissions = { const roleAndPermissions: MeetRoomRoleAndPermissions = {
role, role,
permissions permissions

View File

@ -230,6 +230,18 @@ export const errorParticipantTokenStillValid = (): OpenViduMeetError => {
return new OpenViduMeetError('Participant Error', 'Participant token is still valid', 409); 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 // Handlers
export const handleError = (res: Response, error: OpenViduMeetError | unknown, operationDescription: string) => { export const handleError = (res: Response, error: OpenViduMeetError | unknown, operationDescription: string) => {

View File

@ -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 { inject, injectable } from 'inversify';
import { ParticipantInfo } from 'livekit-server-sdk'; import { ParticipantInfo } from 'livekit-server-sdk';
import { errorParticipantAlreadyExists, errorParticipantNotFound } from '../models/error.model.js'; import { errorParticipantAlreadyExists, errorParticipantNotFound } from '../models/error.model.js';
@ -13,43 +13,53 @@ export class ParticipantService {
@inject(TokenService) protected tokenService: TokenService @inject(TokenService) protected tokenService: TokenService
) {} ) {}
async generateOrRefreshParticipantToken(participantOptions: ParticipantOptions, refresh = false): Promise<string> { async generateOrRefreshParticipantToken(
participantOptions: ParticipantOptions,
currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[],
refresh = false
): Promise<string> {
const { roomId, participantName, secret } = participantOptions; const { roomId, participantName, secret } = participantOptions;
// Check if participant with same participantName exists in the room // Check if participant with same participantName exists in the room
const participantExists = await this.participantExists(roomId, participantName); const participantExists = await this.participantExists(roomId, participantName);
if (!refresh && participantExists) { 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); throw errorParticipantAlreadyExists(participantName, roomId);
} }
if (refresh && !participantExists) { 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); throw errorParticipantNotFound(participantName, roomId);
} }
const role = await this.roomService.getRoomRoleBySecret(roomId, secret); const role = await this.roomService.getRoomRoleBySecret(roomId, secret);
const token = await this.generateParticipantToken(role, participantOptions); const token = await this.generateParticipantToken(participantOptions, role, currentRoles);
this.logger.verbose(`Participant token generated for room ${roomId}`); this.logger.verbose(`Participant token generated for room '${roomId}'`);
return token; return token;
} }
protected async generateParticipantToken( protected async generateParticipantToken(
participantOptions: ParticipantOptions,
role: ParticipantRole, role: ParticipantRole,
participantOptions: ParticipantOptions currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[]
): Promise<string> { ): Promise<string> {
const permissions = this.getParticipantPermissions(role, participantOptions.roomId); const permissions = this.getParticipantPermissions(participantOptions.roomId, role);
return this.tokenService.generateParticipantToken(participantOptions, permissions, 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<ParticipantInfo | null> { async getParticipant(roomId: string, participantName: string): Promise<ParticipantInfo | null> {
this.logger.verbose(`Fetching participant ${participantName}`); this.logger.verbose(`Fetching participant '${participantName}'`);
return this.livekitService.getParticipant(roomId, participantName); return this.livekitService.getParticipant(roomId, participantName);
} }
async participantExists(roomId: string, participantName: string): Promise<boolean> { async participantExists(roomId: string, participantName: string): Promise<boolean> {
this.logger.verbose(`Checking if participant ${participantName} exists in room ${roomId}`); this.logger.verbose(`Checking if participant '${participantName}' exists in room '${roomId}'`);
try { try {
const participant = await this.getParticipant(roomId, participantName); const participant = await this.getParticipant(roomId, participantName);
@ -59,13 +69,13 @@ export class ParticipantService {
} }
} }
async deleteParticipant(participantName: string, roomId: string): Promise<void> { async deleteParticipant(roomId: string, participantName: string): Promise<void> {
this.logger.verbose(`Deleting participant ${participantName} from room ${roomId}`); this.logger.verbose(`Deleting participant '${participantName}' from room '${roomId}'`);
return this.livekitService.deleteParticipant(participantName, roomId); return this.livekitService.deleteParticipant(participantName, roomId);
} }
getParticipantPermissions(role: ParticipantRole, roomId: string): ParticipantPermissions { getParticipantPermissions(roomId: string, role: ParticipantRole): ParticipantPermissions {
switch (role) { switch (role) {
case ParticipantRole.MODERATOR: case ParticipantRole.MODERATOR:
return this.generateModeratorPermissions(roomId); return this.generateModeratorPermissions(roomId);

View File

@ -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 { inject, injectable } from 'inversify';
import { AccessToken, AccessTokenOptions, ClaimGrants, TokenVerifier, VideoGrant } from 'livekit-server-sdk'; import { AccessToken, AccessTokenOptions, ClaimGrants, TokenVerifier, VideoGrant } from 'livekit-server-sdk';
import { import {
@ -11,6 +18,7 @@ import {
MEET_REFRESH_TOKEN_EXPIRATION MEET_REFRESH_TOKEN_EXPIRATION
} from '../environment.js'; } from '../environment.js';
import { LoggerService } from './index.js'; import { LoggerService } from './index.js';
import { jwtDecode } from 'jwt-decode';
@injectable() @injectable()
export class TokenService { export class TokenService {
@ -40,8 +48,8 @@ export class TokenService {
async generateParticipantToken( async generateParticipantToken(
participantOptions: ParticipantOptions, participantOptions: ParticipantOptions,
permissions: ParticipantPermissions, lkPermissions: LiveKitPermissions,
role: ParticipantRole roles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[]
): Promise<string> { ): Promise<string> {
const { roomId, participantName } = participantOptions; const { roomId, participantName } = participantOptions;
this.logger.info(`Generating token for ${participantName} in room ${roomId}`); this.logger.info(`Generating token for ${participantName} in room ${roomId}`);
@ -52,11 +60,10 @@ export class TokenService {
ttl: MEET_PARTICIPANT_TOKEN_EXPIRATION, ttl: MEET_PARTICIPANT_TOKEN_EXPIRATION,
metadata: JSON.stringify({ metadata: JSON.stringify({
livekitUrl: LIVEKIT_URL, livekitUrl: LIVEKIT_URL,
role, roles
permissions: permissions.openvidu
}) })
}; };
return await this.generateJwtToken(tokenOptions, permissions.livekit as VideoGrant); return await this.generateJwtToken(tokenOptions, lkPermissions as VideoGrant);
} }
async generateRecordingToken( async generateRecordingToken(
@ -92,4 +99,17 @@ export class TokenService {
const verifyer = new TokenVerifier(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); const verifyer = new TokenVerifier(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
return await verifyer.verify(token); return await verifyer.verify(token);
} }
/**
* Decodes a JWT and returns its ClaimGrants, even if expired.
*/
getClaimsIgnoringExpiration(token: string): ClaimGrants {
try {
const decoded = jwtDecode<ClaimGrants>(token);
return decoded;
} catch (error) {
this.logger.error('Failed to decode JWT:', error);
throw error;
}
}
} }

View File

@ -2,12 +2,12 @@ import { CookieOptions } from 'express';
import ms, { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { MEET_COOKIE_SECURE } from '../environment.js'; import { MEET_COOKIE_SECURE } from '../environment.js';
export const getCookieOptions = (path: string, expiration: string): CookieOptions => { export const getCookieOptions = (path: string, expiration?: string): CookieOptions => {
return { return {
httpOnly: true, httpOnly: true,
secure: MEET_COOKIE_SECURE === 'true', secure: MEET_COOKIE_SECURE === 'true',
sameSite: 'strict', sameSite: 'strict',
maxAge: ms(expiration as StringValue), maxAge: expiration ? ms(expiration as StringValue) : undefined,
path path
}; };
}; };