backend: upgrade participant token generation to include multiple roles in the same token
This commit is contained in:
parent
166c3573d7
commit
22ce0e7d66
10
backend/package-lock.json
generated
10
backend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}'`);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user