backend: enhance room member token generation with support for recording access and update related tests

This commit is contained in:
juancarmore 2026-03-03 10:02:39 +01:00
parent f49fd863b7
commit e05dfdf001
8 changed files with 151 additions and 38 deletions

View File

@ -4,7 +4,13 @@ required:
properties:
secret:
type: string
description: A secret key for room access. Determines the member's role.
description: |
A secret key for room access.
Supported secret types:
- Moderator anonymous access secret
- Speaker anonymous access secret
- Recording anonymous access secret (generates a token with read-only recording permissions)
example: 'abc123456'
joinMeeting:
type: boolean

View File

@ -4,6 +4,7 @@
summary: Generate room member token
description: >
Generates a token for a user to access an OpenVidu Meet room and its resources.
When using the anonymous recording secret, generated tokens only allow retrieval of room recordings.
tags:
- Internal API - Room Members
security:

View File

@ -283,10 +283,13 @@ export const errorInvalidRoomSecret = (roomId: string, secret: string): OpenVidu
return new OpenViduMeetError('Room Error', `Secret '${secret}' is not recognized for room '${roomId}'`, 400);
};
export const errorAnonymousAccessDisabled = (roomId: string, role: MeetRoomMemberRole): OpenViduMeetError => {
export const errorAnonymousAccessDisabled = (
roomId: string,
role: MeetRoomMemberRole | 'recording'
): OpenViduMeetError => {
return new OpenViduMeetError(
'Room Error',
`Anonymous access in room '${roomId}' is disabled for role '${role}'`,
`Anonymous access in room '${roomId}' is disabled for ${role === 'recording' ? 'recordings' : `role '${role}'`}`,
403
);
};

View File

@ -417,15 +417,9 @@ export class RoomMemberService {
effectivePermissions = member.effectivePermissions;
} else {
// If secret matches anonymous access URL secret, assign role and permissions based on it
baseRole = await this.getRoomMemberRoleBySecret(roomId, secret);
const { roles, access } = await this.roomService.getMeetRoom(roomId, ['roles', 'access']);
// Check that anonymous access is enabled for the role
if (!access.anonymous[baseRole].enabled) {
throw errorAnonymousAccessDisabled(roomId, baseRole);
}
effectivePermissions = roles[baseRole].permissions;
const anonymousAccess = await this.resolveAnonymousAccessBySecret(roomId, secret);
baseRole = anonymousAccess.baseRole;
effectivePermissions = anonymousAccess.effectivePermissions;
}
} else {
// Case 2: Authenticated user
@ -475,6 +469,49 @@ export class RoomMemberService {
return this.generateToken(roomId, baseRole, effectivePermissions, customPermissions, memberId);
}
/**
* Resolves anonymous access and effective permissions from a room secret.
*
* - Moderator and speaker secrets map to their room role permissions.
* - Recording secret maps to read-only recording permissions.
*/
protected async resolveAnonymousAccessBySecret(
roomId: string,
secret: string
): Promise<{ baseRole: MeetRoomMemberRole; effectivePermissions: MeetRoomMemberPermissions }> {
const { roles, access } = await this.roomService.getMeetRoom(roomId, ['roles', 'access']);
const { moderatorSecret, speakerSecret, recordingSecret } = MeetRoomHelper.extractSecretsFromRoom(access);
const anonymousRole: MeetRoomMemberRole | 'recording' | undefined =
secret === moderatorSecret
? MeetRoomMemberRole.MODERATOR
: secret === speakerSecret
? MeetRoomMemberRole.SPEAKER
: secret === recordingSecret
? 'recording'
: undefined;
if (!anonymousRole) {
throw errorInvalidRoomSecret(roomId, secret);
}
if (!access.anonymous[anonymousRole].enabled) {
throw errorAnonymousAccessDisabled(roomId, anonymousRole);
}
if (anonymousRole === 'recording') {
return {
baseRole: MeetRoomMemberRole.SPEAKER,
effectivePermissions: this.getRecordingReadOnlyPermissions()
};
}
return {
baseRole: anonymousRole,
effectivePermissions: roles[anonymousRole].permissions
};
}
/**
* Generates a token for joining a meeting.
* Handles both new token generation and token refresh.
@ -598,30 +635,6 @@ export class RoomMemberService {
});
}
/**
* Validates a secret against a room's moderator and speaker secrets and returns the corresponding role.
*
* @param roomId - The unique identifier of the room to check
* @param secret - The secret to validate against the room's moderator and speaker secrets
* @returns A promise that resolves to the room member role (MODERATOR or SPEAKER) if the secret is valid
* @throws Error if room not found
* @throws Error if the moderator or speaker secrets cannot be extracted from their URLs
* @throws Error if the provided secret doesn't match any of the room's secrets (unauthorized)
*/
protected async getRoomMemberRoleBySecret(roomId: string, secret: string): Promise<MeetRoomMemberRole> {
const { access } = await this.roomService.getMeetRoom(roomId, ['access']);
const { moderatorSecret, speakerSecret } = MeetRoomHelper.extractSecretsFromRoom(access);
switch (secret) {
case moderatorSecret:
return MeetRoomMemberRole.MODERATOR;
case speakerSecret:
return MeetRoomMemberRole.SPEAKER;
default:
throw errorInvalidRoomSecret(roomId, secret);
}
}
/**
* Gets all permissions set to true.
*/
@ -666,6 +679,16 @@ export class RoomMemberService {
};
}
/**
* Gets a permission set that only allows retrieving recordings.
*/
getRecordingReadOnlyPermissions(): MeetRoomMemberPermissions {
return {
...this.getNoPermissions(),
canRetrieveRecordings: true
};
}
/**
* Gets the LiveKit permissions for a room member based on their Meet permissions.
*

View File

@ -51,7 +51,7 @@ export const setupSingleRoom = async (
});
// Extract the room secrets and generate room member tokens
const { moderatorSecret, speakerSecret } = MeetRoomHelper.extractSecretsFromRoom(room.access);
const { moderatorSecret, speakerSecret, recordingSecret } = MeetRoomHelper.extractSecretsFromRoom(room.access);
const [moderatorToken, speakerToken] = await Promise.all([
generateRoomMemberToken(room.roomId, { secret: moderatorSecret, joinMeeting: false }),
generateRoomMemberToken(room.roomId, { secret: speakerSecret, joinMeeting: false })
@ -67,7 +67,8 @@ export const setupSingleRoom = async (
moderatorSecret,
moderatorToken,
speakerSecret,
speakerToken
speakerToken,
recordingSecret
};
};

View File

@ -42,6 +42,23 @@ const allPermissions: MeetRoomMemberPermissions = {
canChangeVirtualBackground: true
};
const recordingReadOnlyPermissions: MeetRoomMemberPermissions = {
canRecord: false,
canRetrieveRecordings: true,
canDeleteRecordings: false,
canJoinMeeting: false,
canShareAccessLinks: false,
canMakeModerator: false,
canKickParticipants: false,
canEndMeeting: false,
canPublishVideo: false,
canPublishAudio: false,
canShareScreen: false,
canReadChat: false,
canWriteChat: false,
canChangeVirtualBackground: false
};
describe('Room Members API Tests', () => {
let roomData: RoomData;
let roomId: string;
@ -131,6 +148,57 @@ describe('Room Members API Tests', () => {
});
});
it('should generate read-only recording token when anonymous.recording.enabled is true', async () => {
expect(roomData.recordingSecret).toBeDefined();
const recordingSecret = roomData.recordingSecret!;
await updateRoomAccessConfig(roomId, {
anonymous: {
recording: { enabled: true }
}
});
const response = await generateRoomMemberTokenRequest(roomId, { secret: recordingSecret });
expectValidRoomMemberTokenResponse(response, {
roomId,
baseRole: MeetRoomMemberRole.SPEAKER,
effectivePermissions: recordingReadOnlyPermissions
});
});
it('should fail to generate recording token when anonymous.recording.enabled is false', async () => {
expect(roomData.recordingSecret).toBeDefined();
const recordingSecret = roomData.recordingSecret!;
await updateRoomAccessConfig(roomId, {
anonymous: {
recording: { enabled: false }
}
});
const response = await generateRoomMemberTokenRequest(roomId, { secret: recordingSecret });
expect(response.status).toBe(403);
await updateRoomAccessConfig(roomId, {
anonymous: {
recording: { enabled: true }
}
});
});
it('should fail to generate recording token for joining meeting', async () => {
expect(roomData.recordingSecret).toBeDefined();
const recordingSecret = roomData.recordingSecret!;
const response = await generateRoomMemberTokenRequest(roomId, {
secret: recordingSecret,
joinMeeting: true,
participantName: 'Recording Viewer'
});
expect(response.status).toBe(403);
});
it('should fail to generate token when secret is invalid', async () => {
const response = await generateRoomMemberTokenRequest(roomId, {
secret: 'invalid-secret'

View File

@ -538,6 +538,16 @@ describe('Room Members API Security Tests', () => {
expect(response.status).toBe(200);
});
it('should succeed when using room recording secret', async () => {
expect(roomData.recordingSecret).toBeDefined();
const response = await request(app).post(`${INTERNAL_ROOMS_PATH}/${roomId}/members/token`).send({
secret: roomData.recordingSecret,
joinMeeting: false
});
expect(response.status).toBe(200);
});
it('should fail when using invalid room secret', async () => {
const response = await request(app).post(`${INTERNAL_ROOMS_PATH}/${roomId}/members/token`).send({
secret: 'invalid_secret',

View File

@ -6,6 +6,7 @@ export interface RoomData {
moderatorToken: string;
speakerSecret: string;
speakerToken: string;
recordingSecret?: string;
recordingId?: string;
users?: RoomTestUsers;
}