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",
"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",

View File

@ -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",

View File

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

View File

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

View File

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

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 { 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<string> {
async generateOrRefreshParticipantToken(
participantOptions: ParticipantOptions,
currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[],
refresh = false
): Promise<string> {
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<string> {
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<ParticipantInfo | null> {
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<boolean> {
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<void> {
this.logger.verbose(`Deleting participant ${participantName} from room ${roomId}`);
async deleteParticipant(roomId: string, participantName: string): Promise<void> {
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);

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 { 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<string> {
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<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 { 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
};
};