Delete participant and recording tokens and implement room member token. Remove unused cookie transport mode for tokens

This commit is contained in:
juancarmore 2025-11-14 11:23:25 +01:00
parent a56a119993
commit 6eb33c6198
170 changed files with 2478 additions and 4612 deletions

View File

@ -38,7 +38,7 @@ jobs:
fail-fast: false
matrix:
include:
- test-name: 'Room Management API Tests (Rooms, Meetings, Participants)'
- test-name: 'Room Management API Tests (Rooms, Meetings)'
test-script: 'test:integration-backend-room-management'
- test-name: 'Webhook Tests'
test-script: 'test:integration-backend-webhooks'

View File

@ -1,6 +0,0 @@
description: >
The cookie containing the access token.
This cookie is used to authenticate the user in subsequent requests.
schema:
type: string
example: 'OvMeetAccessToken=token_123456; Path=/; HttpOnly; SameSite=Strict'

View File

@ -1,6 +0,0 @@
description: >
The cookie containing the participant token.
This cookie is used to authenticate the participant in the room.
schema:
type: string
example: 'OvMeetParticipantToken=token_123456; Path=/; HttpOnly; SameSite=Strict'

View File

@ -1,6 +0,0 @@
description: >
The cookie containing the recording token.
This cookie is used to access the recordings in a room.
schema:
type: string
example: 'OvMeetRecordingToken=token_123456; Path=/; HttpOnly; SameSite=Strict'

View File

@ -1,6 +0,0 @@
description: >
The cookie containing the refresh token.
This cookie is used to refresh the access token when it expires.
schema:
type: string
example: 'OvMeetRefreshToken=token_123456; Path=/meet/internal-api/v1/auth; HttpOnly; SameSite=Strict'

View File

@ -1,7 +0,0 @@
name: participantIdentity
in: path
required: true
description: The identity of the participant.
schema:
type: string
example: 'Alice'

View File

@ -1,6 +1,6 @@
name: secret
in: path
required: true
description: The secret value from the room URL used to connect to the room.
description: The secret value from the room URL used to access the room.
schema:
type: string

View File

@ -1,12 +0,0 @@
name: x-participant-role
in: header
description: |
The role of the participant in the meeting. It can be one of the following values:
- `moderator`: Can manage the room and its participants.
- `speaker`: Can publish media streams to the room.
This is required to distinguish roles when multiple are present in the participant token
required: true
schema:
type: string
enum: ['moderator', 'speaker']

View File

@ -1,12 +1,7 @@
name: status
in: query
required: false
description: |
Filter recordings by their status.
You can provide multiple statuses as a comma-separated list (e.g., `status=active,failed`).
> ⚠️ **Note:** Using this filter may impact performance for large datasets.
description: Filter recordings by their status.
schema:
type: string
enum:

View File

@ -1,6 +0,0 @@
description: Participant details
required: true
content:
application/json:
schema:
$ref: '../../schemas/internal/meet-participant-options.yaml'

View File

@ -0,0 +1,6 @@
description: Room member token options
required: true
content:
application/json:
schema:
$ref: '../../schemas/internal/room-member-token-options.yaml'

View File

@ -0,0 +1,17 @@
description: New room status
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum:
- open
- active_meeting
- closed
example: closed
description: |
The new status of the room. Options are:
- open: The room will be open for new participants to join.
- closed: The room will be closed to new participants.

View File

@ -1,8 +0,0 @@
description: Invalid participant role provided
content:
application/json:
schema:
$ref: '../../schemas/error.yaml'
example:
error: Participant Error
message: 'No valid participant role provided'

View File

@ -1,8 +0,0 @@
description: Conflict — The participant already exists in the room
content:
application/json:
schema:
$ref: '../../schemas/error.yaml'
example:
error: 'Participant Error'
message: 'Participant "Alice" already exists in room "room_123"'

View File

@ -1,8 +0,0 @@
description: Room metadata not found
content:
application/json:
schema:
$ref: '../../schemas/error.yaml'
example:
error: 'Room Error'
message: 'Room metadata for "room_123" not found. Room "room_123" does not exist or has no recordings associated'

View File

@ -1,13 +0,0 @@
description: Successfully generated the participant token
# headers:
# Set-Cookie:
# $ref: '../../headers/set-cookie-participant-token.yaml'
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: >
The token to authenticate the participant.

View File

@ -1,13 +0,0 @@
description: Successfully generated the recording token
# headers:
# Set-Cookie:
# $ref: '../../headers/set-cookie-recording-token.yaml'
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: >
The token to access the recordings in the specified OpenVidu Meet room.

View File

@ -0,0 +1,10 @@
description: Successfully generated the room member token
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: >
The token to authenticate the user to access the room and its resources.

View File

@ -2,4 +2,4 @@ description: Successfully retrieved user profile
content:
application/json:
schema:
$ref: '../../schemas/internal/user.yaml'
$ref: '../../schemas/internal/meet-user.yaml'

View File

@ -2,4 +2,4 @@ description: Successfully retrieved the room role and associated permissions
content:
application/json:
schema:
$ref: '../../schemas/internal/meet-room-role-permissions.yaml'
$ref: '../../schemas/internal/room-member-role-permissions.yaml'

View File

@ -4,7 +4,7 @@ content:
schema:
type: array
items:
$ref: '../../schemas/internal/meet-room-role-permissions.yaml'
$ref: '../../schemas/internal/room-member-role-permissions.yaml'
example:
- role: 'moderator'
permissions:
@ -17,6 +17,8 @@ content:
canUpdateOwnMetadata: true
openvidu:
canRecord: true
canRetrieveRecordings: true
canDeleteRecordings: true
canChat: true
canChangeVirtualBackground: true
- role: 'speaker'
@ -30,5 +32,7 @@ content:
canUpdateOwnMetadata: true
openvidu:
canRecord: false
canRetrieveRecordings: true
canDeleteRecordings: false
canChat: true
canChangeVirtualBackground: true

View File

@ -6,4 +6,4 @@ content:
properties:
message:
type: string
example: Participant 'Alice' kicked successfully from room 'room-123'
example: Participant 'Alice' kicked successfully from meeting in room 'room-123'

View File

@ -1,7 +1,4 @@
description: Successfully refreshed the access token
# headers:
# Set-Cookie:
# $ref: '../../headers/set-cookie-access-token.yaml'
content:
application/json:
schema:

View File

@ -1,9 +1,4 @@
description: Successfully logged in
# headers:
# Set-Cookie:
# $ref: '../../headers/set-cookie-access-token.yaml'
# Set-Cookie*:
# $ref: '../../headers/set-cookie-refresh-token.yaml'
content:
application/json:
schema:

View File

@ -1,11 +1,4 @@
description: Successfully logged out
# headers:
# Set-Cookie:
# description: >
# Clears the access and refresh token cookie.
# schema:
# type: string
# example: 'OvMeetAccessToken=; Path=/; HttpOnly; SameSite=Strict'
content:
application/json:
schema:

View File

@ -0,0 +1,10 @@
description: Success response for scheduling room closure
content:
application/json:
schema:
type: object
properties:
message:
type: string
example:
message: Room 'room-123' scheduled to be closed when the meeting ends

View File

@ -1,12 +0,0 @@
description: >
All specified rooms were marked for deletion (due to active participants)
and will be removed once all participants leave.
content:
application/json:
schema:
type: object
properties:
message:
type: string
example:
message: Rooms 'room-123, room-456' marked for deletion

View File

@ -0,0 +1,10 @@
description: Success response for updating room status
content:
application/json:
schema:
type: object
properties:
message:
type: string
example:
message: Room 'room-123' closed successfully

View File

@ -1,21 +0,0 @@
type: object
required:
- roomId
- secret
properties:
roomId:
type: string
description: The unique identifier of the room where the participant will join.
example: 'room-123'
secret:
type: string
description: The secret token from the room Url
example: 'abc123456'
participantName:
type: string
description: The name of the participant.
example: 'Alice'
participantIdentity:
type: string
description: The unique identity of the participant.
example: 'Alice'

View File

@ -4,10 +4,10 @@ properties:
type: string
enum: ['moderator', 'speaker']
description: |
A role that a participant can have in a room.
The role determines the permissions of the participant in the room.
- `moderator`: Can manage the room and its participants.
- `speaker`: Can publish media streams to the room.
A role that a user can have as a member of a room.
The role determines the permissions of the user in the room.
- `moderator`: Can manage the room resources and meeting participants.
- `speaker`: Can publish media streams to the meeting.
example: 'moderator'
permissions:
type: object
@ -50,15 +50,25 @@ properties:
canRecord:
type: boolean
description: >
Indicates whether the participant can record the room.
Indicates whether the user can record a meeting in the room.
example: true
canRetrieveRecordings:
type: boolean
description: >
Indicates whether the user can retrieve and play recordings of meetings in the room.
example: true
canDeleteRecordings:
type: boolean
description: >
Indicates whether the user can delete recordings of meetings in the room.
example: true
canChat:
type: boolean
description: >
Indicates whether the participant can send and receive chat messages in the room.
Indicates whether the user can send and receive chat messages in the room.
example: true
canChangeVirtualBackground:
type: boolean
description: >
Indicates whether the participant can change their own virtual background.
Indicates whether the user can change their own virtual background.
example: true

View File

@ -0,0 +1,20 @@
type: object
required:
- secret
properties:
secret:
type: string
description: A secret key for room access. Determines the member's role.
example: 'abc123456'
grantJoinMeetingPermission:
type: boolean
description: Whether to grant permission to join the meeting. If true, participantName must be provided.
example: true
participantName:
type: string
description: The name of the participant when joining the meeting. Required if `grantJoinMeetingPermission` is true and this is a new token (not a refresh).
example: 'Alice'
participantIdentity:
type: string
description: The identity of the participant in the meeting. Required when refreshing an existing token with meeting permissions.
example: 'Alice'

View File

@ -26,6 +26,10 @@ MeetRoomTheme:
minLength: 1
maxLength: 50
example: "Default Theme"
enabled:
type: boolean
description: Whether the theme is enabled
example: true
baseTheme:
$ref: '#/MeetRoomThemeMode'
description: Base theme mode (light or dark)
@ -51,6 +55,7 @@ MeetRoomTheme:
example: "#CCCCCC"
required:
- name
- enabled
- baseTheme
MeetRoomThemeMode:

View File

@ -58,6 +58,6 @@ MeetE2EEConfig:
default: false
example: false
description: >
If true, the room will have End-to-End Encryption (E2EE) enabled.<br/>
This ensures that the media streams are encrypted from the sender to the receiver, providing enhanced privacy and security for the participants.<br/>
**Enabling E2EE will disable the recording feature for the room**.
If true, the room will have End-to-End Encryption (E2EE) enabled.<br/>
This ensures that the media streams are encrypted from the sender to the receiver, providing enhanced privacy and security for the participants.<br/>
**Enabling E2EE will disable the recording feature for the room**.

View File

@ -71,14 +71,14 @@ properties:
type: string
example: 'http://localhost:6080/room/room-123?secret=123456'
description: >
The URL for the moderator participants to join the room. The moderator role has special permissions to manage the
room and participants.
The URL for moderator room members to access the room. The moderator role has special permissions to manage the
room resources and meeting participants.
speakerUrl:
type: string
example: 'http://localhost:6080/room/room-123?secret=654321'
description: >
The URL for the speaker participants to join the room. The speaker role has permissions to publish audio and
video streams to the room.
The URL for speaker room members to access the room. The speaker role has permissions to publish audio and
video streams to the meeting.
status:
type: string
enum:

View File

@ -4,51 +4,21 @@ apiKeyHeader:
in: header
description: >
The API key to authenticate the request. This key is required to access the OpenVidu Meet API.
# accessTokenCookie:
# type: apiKey
# name: OvMeetAccessToken
# in: cookie
# description: >
# The JWT token to authenticate the request in case of consuming the API from the OpenVidu Meet frontend.
accessTokenHeader:
type: apiKey
name: Authorization
in: header
description: >
The JWT token to authenticate the request in case of consuming the API from the OpenVidu Meet frontend.
# refreshTokenCookie:
# type: apiKey
# name: OvMeetRefreshToken
# in: cookie
# description: >
# The JWT token to refresh the access token when it expires.
refreshTokenHeader:
type: apiKey
name: X-Refresh-Token
in: header
description: >
The JWT token to refresh the access token when it expires.
# participantTokenCookie:
# type: apiKey
# name: OvMeetParticipantToken
# in: cookie
# description: >
# The JWT token to authenticate the participant when entering the room.
participantTokenHeader:
roomMemberTokenHeader:
type: apiKey
name: X-Participant-Token
name: X-Room-Member-Token
in: header
description: >
The JWT token to authenticate the participant when entering the room.
# recordingTokenCookie:
# type: apiKey
# name: OvMeetRecordingToken
# in: cookie
# description: >
# The JWT token containing permissions to access the recordings in a room.
recordingTokenHeader:
type: apiKey
name: X-Recording-Token
in: header
description: >
The JWT token containing permissions to access the recordings in a room.
The JWT token to authenticate a room member when accessing room and its resources.

View File

@ -15,6 +15,8 @@ paths:
$ref: './paths/rooms.yaml#/~1rooms~1{roomId}'
/rooms/{roomId}/config:
$ref: './paths/rooms.yaml#/~1rooms~1{roomId}~1config'
/rooms/{roomId}/status:
$ref: './paths/rooms.yaml#/~1rooms~1{roomId}~1status'
/recordings:
$ref: './paths/recordings.yaml#/~1recordings'
/recordings/download:
@ -37,7 +39,7 @@ components:
$ref: './components/schemas/meet-room-config.yaml#/MeetRoomConfig'
MeetRecording:
$ref: components/schemas/meet-recording.yaml
Error:
$ref: components/schemas/error.yaml
MeetWebhookEvent:
$ref: components/schemas/meet-webhook.yaml#/MeetWebhookEvent
Error:
$ref: components/schemas/error.yaml

View File

@ -28,8 +28,8 @@ paths:
$ref: './paths/internal/meet-global-config.yaml#/~1config~1security'
/config/rooms/appearance:
$ref: './paths/internal/meet-global-config.yaml#/~1config~1rooms~1appearance'
/rooms/{roomId}/recording-token:
$ref: './paths/internal/rooms.yaml#/~1rooms~1{roomId}~1recording-token'
/rooms/{roomId}/token:
$ref: './paths/internal/rooms.yaml#/~1rooms~1{roomId}~1token'
/rooms/{roomId}/roles:
$ref: './paths/internal/rooms.yaml#/~1rooms~1{roomId}~1roles'
/rooms/{roomId}/roles/{secret}:
@ -38,10 +38,6 @@ paths:
$ref: './paths/internal/recordings.yaml#/~1recordings'
/recordings/{recordingId}/stop:
$ref: './paths/internal/recordings.yaml#/~1recordings~1{recordingId}~1stop'
/participants/token:
$ref: './paths/internal/participants.yaml#/~1participants~1token'
/participants/token/refresh:
$ref: './paths/internal/participants.yaml#/~1participants~1token~1refresh'
/meetings/{roomId}:
$ref: './paths/internal/meetings.yaml#/~1meetings~1{roomId}'
/meetings/{roomId}/participants/{participantIdentity}:
@ -55,8 +51,10 @@ components:
securitySchemes:
$ref: components/security.yaml
schemas:
User:
$ref: components/schemas/internal/user.yaml
MeetApiKey:
$ref: components/schemas/internal/meet-api-key.yaml
MeetUser:
$ref: components/schemas/internal/meet-user.yaml
WebhooksConfig:
$ref: components/schemas/internal/webhooks-config.yaml
SecurityConfig:
@ -67,8 +65,8 @@ components:
$ref: components/schemas/meet-room-options.yaml
MeetRoomConfig:
$ref: components/schemas/meet-room-config.yaml#/MeetRoomConfig
MeetRoomRoleAndPermissions:
$ref: components/schemas/internal/meet-room-role-permissions.yaml
MeetRoomMemberRoleAndPermissions:
$ref: components/schemas/internal/room-member-role-permissions.yaml
MeetAnalytics:
$ref: components/schemas/internal/meet-analytics.yaml
MeetRecording:

View File

@ -9,15 +9,12 @@
tags:
- Internal API - Meetings
security:
- participantTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../../components/parameters/room-id-path.yaml'
- $ref: '../../components/parameters/internal/x-participant-role.yaml'
responses:
'200':
$ref: '../../components/responses/internal/success-end-meeting.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-participant-role.yaml'
'401':
$ref: '../../components/responses/unauthorized-error.yaml'
'403':
@ -35,16 +32,12 @@
tags:
- Internal API - Meetings
security:
- participantTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../../components/parameters/room-id-path.yaml'
- $ref: '../../components/parameters/internal/participant-identity.yaml'
- $ref: '../../components/parameters/internal/x-participant-role.yaml'
responses:
'200':
$ref: '../../components/responses/internal/success-kick-participant.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-participant-role.yaml'
'401':
$ref: '../../components/responses/unauthorized-error.yaml'
'403':
@ -62,18 +55,14 @@
tags:
- Internal API - Meetings
security:
- participantTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../../components/parameters/room-id-path.yaml'
- $ref: '../../components/parameters/internal/participant-identity.yaml'
- $ref: '../../components/parameters/internal/x-participant-role.yaml'
requestBody:
$ref: '../../components/requestBodies/internal/update-participant-role-request.yaml'
responses:
'200':
$ref: '../../components/responses/internal/success-update-participant-role.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-participant-role.yaml'
'401':
$ref: '../../components/responses/unauthorized-error.yaml'
'403':

View File

@ -1,58 +0,0 @@
/participants/token:
post:
operationId: generateParticipantToken
summary: Generate token for participant
description: >
Generates a token for a participant to join an OpenVidu Meet room.
tags:
- Internal API - Participant
security:
- accessTokenHeader: []
requestBody:
$ref: '../../components/requestBodies/internal/participant-token-request.yaml'
responses:
'200':
$ref: '../../components/responses/internal/success-generate-participant-token.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-room-secret.yaml'
'401':
$ref: '../../components/responses/unauthorized-error.yaml'
'403':
$ref: '../../components/responses/forbidden-error.yaml'
'404':
$ref: '../../components/responses/error-room-not-found.yaml'
'409':
$ref: '../../components/responses/internal/error-room-closed.yaml'
'422':
$ref: '../../components/responses/validation-error.yaml'
'500':
$ref: '../../components/responses/internal-server-error.yaml'
/participants/token/refresh:
post:
operationId: refreshParticipantToken
summary: Refresh token for participant
description: >
Refresh a token for a participant in an OpenVidu Meet room.
tags:
- Internal API - Participant
security:
- accessTokenHeader: []
requestBody:
$ref: '../../components/requestBodies/internal/participant-token-request.yaml'
responses:
'200':
$ref: '../../components/responses/internal/success-generate-participant-token.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-room-secret.yaml'
'401':
$ref: '../../components/responses/unauthorized-error.yaml'
'403':
$ref: '../../components/responses/forbidden-error.yaml'
'404':
$ref: '../../components/responses/internal/error-room-participant-not-found.yaml'
'409':
$ref: '../../components/responses/internal/error-room-closed.yaml'
'422':
$ref: '../../components/responses/validation-error.yaml'
'500':
$ref: '../../components/responses/internal-server-error.yaml'

View File

@ -7,16 +7,12 @@
tags:
- Internal API - Recordings
security:
- participantTokenHeader: []
parameters:
- $ref: '../../components/parameters/internal/x-participant-role.yaml'
- roomMemberTokenHeader: []
requestBody:
$ref: '../../components/requestBodies/internal/start-recording-request.yaml'
responses:
'201':
$ref: '../../components/responses/internal/success-start-recording.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-participant-role.yaml'
'401':
$ref: '../../components/responses/unauthorized-error.yaml'
'403':
@ -42,15 +38,12 @@
tags:
- Internal API - Recordings
security:
- participantTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../../components/parameters/recording-id.yaml'
- $ref: '../../components/parameters/internal/x-participant-role.yaml'
responses:
'202':
$ref: '../../components/responses/internal/success-stop-recording.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-participant-role.yaml'
'401':
$ref: '../../components/responses/unauthorized-error.yaml'
'403':

View File

@ -1,10 +1,9 @@
/rooms/{roomId}/recording-token:
/rooms/{roomId}/token:
post:
operationId: generateRecordingToken
summary: Generate recording token
operationId: generateRoomMemberToken
summary: Generate room member token
description: >
Generates a token with recording permissions for a specified OpenVidu Meet room.
This token can be used to access the recordings in a room.
Generates a token for a user to access an OpenVidu Meet room and its resources.
tags:
- Internal API - Rooms
security:
@ -12,10 +11,10 @@
parameters:
- $ref: '../../components/parameters/room-id-path.yaml'
requestBody:
$ref: '../../components/requestBodies/internal/recording-token-request.yaml'
$ref: '../../components/requestBodies/internal/room-member-token-request.yaml'
responses:
'200':
$ref: '../../components/responses/internal/success-generate-recording-token.yaml'
$ref: '../../components/responses/internal/success-generate-room-member-token.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-room-secret.yaml'
'401':
@ -23,24 +22,26 @@
'403':
$ref: '../../components/responses/forbidden-error.yaml'
'404':
$ref: '../../components/responses/internal/error-room-metadata-not-found.yaml'
$ref: '../../components/responses/internal/error-room-participant-not-found.yaml'
'409':
$ref: '../../components/responses/internal/error-room-closed.yaml'
'422':
$ref: '../../components/responses/validation-error.yaml'
'500':
$ref: '../../components/responses/internal-server-error.yaml'
/rooms/{roomId}/roles:
get:
operationId: getRoomRolesAndPermissions
summary: Get room roles and permissions
operationId: getRoomMemberRolesAndPermissions
summary: Get room member roles and permissions
description: >
Retrieves the roles and associated permissions that a participant can have in a specified OpenVidu Meet room.
Retrieves the roles and associated permissions that a user can have as a member of a specified OpenVidu Meet room.
tags:
- Internal API - Rooms
parameters:
- $ref: '../../components/parameters/room-id-path.yaml'
responses:
'200':
$ref: '../../components/responses/internal/success-get-room-roles.yaml'
$ref: '../../components/responses/internal/success-get-room-member-roles.yaml'
'404':
$ref: '../../components/responses/error-room-not-found.yaml'
'422':
@ -52,10 +53,10 @@
operationId: getRoomRoleAndPermissions
summary: Get room role and permissions
description: |
Retrieves the role and associated permissions that a participant will have in a specified OpenVidu Meet room
when using the URL thant contains the given secret value.
Retrieves the role and associated permissions that a user will have as a member of a specified OpenVidu Meet room
when using the URL that contains the given secret value.
This endpoint is useful for checking the participant's role and permissions before joining the room.
This endpoint is useful for checking the user's role and permissions before accessing the room.
tags:
- Internal API - Rooms
parameters:
@ -63,7 +64,7 @@
- $ref: '../../components/parameters/internal/secret.yaml'
responses:
'200':
$ref: '../../components/responses/internal/success-get-room-role.yaml'
$ref: '../../components/responses/internal/success-get-room-member-role.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-room-secret.yaml'
'404':

View File

@ -6,14 +6,14 @@
Retrieves a paginated list of all recordings available in the system.
You can apply filters to narrow down the results based on specific criteria.
> **Note:** If this endpoint is called using the `recordingTokenHeader` authentication method,
> **Note:** If this endpoint is called using the `roomMemberTokenHeader` authentication method,
> the `roomId` filter will be ignored and only recordings associated with the room included in the token will be returned.
tags:
- OpenVidu Meet - Recordings
security:
- apiKeyHeader: []
- accessTokenHeader: []
- recordingTokenHeader: []
- roomMemberTokenHeader: []
parameters:
# - $ref: '../components/parameters/recording-status.yaml'
- $ref: '../components/parameters/recording-fields.yaml'
@ -39,7 +39,7 @@
description: |
Deletes multiple recordings at once with the specified recording IDs.
> **Note:** If this endpoint is called using the `recordingTokenHeader` authentication method,
> **Note:** If this endpoint is called using the `roomMemberTokenHeader` authentication method,
> all specified recordings must belong to the same room included in the token.
> If a recording does not belong to that room, it will not be deleted.
tags:
@ -47,7 +47,7 @@
security:
- apiKeyHeader: []
- accessTokenHeader: []
- recordingTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../components/parameters/recording-ids.yaml'
responses:
@ -72,7 +72,7 @@
Downloads multiple recordings as a ZIP file with the specified recording IDs.
The ZIP file will contain all recordings in MP4 format.
> **Note:** If this endpoint is called using the `recordingTokenHeader` authentication method,
> **Note:** If this endpoint is called using the `roomMemberTokenHeader` authentication method,
> all specified recordings must belong to the same room included in the token.
> If a recording does not belong to that room, it will not be included in the ZIP file.
tags:
@ -80,7 +80,7 @@
security:
- apiKeyHeader: []
- accessTokenHeader: []
- recordingTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../components/parameters/recording-ids.yaml'
responses:
@ -121,7 +121,7 @@
security:
- apiKeyHeader: []
- accessTokenHeader: []
- recordingTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../components/parameters/recording-id.yaml'
- $ref: '../components/parameters/recording-secret.yaml'
@ -153,7 +153,7 @@
security:
- apiKeyHeader: []
- accessTokenHeader: []
- recordingTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../components/parameters/recording-id.yaml'
responses:
@ -186,7 +186,7 @@
security:
- apiKeyHeader: []
- accessTokenHeader: []
- recordingTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../components/parameters/recording-id.yaml'
- $ref: '../components/parameters/recording-secret.yaml'
@ -260,7 +260,7 @@
security:
- apiKeyHeader: []
- accessTokenHeader: []
- recordingTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../components/parameters/recording-id.yaml'
# - $ref: '../components/parameters/private-access.yaml'

View File

@ -94,16 +94,13 @@
security:
- apiKeyHeader: []
- accessTokenHeader: []
- participantTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../components/parameters/room-id-path.yaml'
- $ref: '../components/parameters/room-fields.yaml'
- $ref: '../components/parameters/internal/x-participant-role.yaml'
responses:
'200':
$ref: '../components/responses/success-get-room.yaml'
'400':
$ref: '../components/responses/internal/error-invalid-participant-role.yaml'
'401':
$ref: '../components/responses/unauthorized-error.yaml'
'403':
@ -162,15 +159,12 @@
security:
- apiKeyHeader: []
- accessTokenHeader: []
- participantTokenHeader: []
- roomMemberTokenHeader: []
parameters:
- $ref: '../components/parameters/room-id-path.yaml'
- $ref: '../components/parameters/internal/x-participant-role.yaml'
responses:
'200':
$ref: '../components/responses/success-get-room-config.yaml'
'400':
$ref: '../components/responses/internal/error-invalid-participant-role.yaml'
'401':
$ref: '../components/responses/unauthorized-error.yaml'
'403':
@ -210,3 +204,34 @@
$ref: '../components/responses/validation-error.yaml'
'500':
$ref: '../components/responses/internal-server-error.yaml'
/rooms/{roomId}/status:
put:
operationId: updateRoomStatus
summary: Update room status
description: >
Updates the status of an OpenVidu Meet room with the specified room ID.
This can be used to open or close the room for new participants.
tags:
- OpenVidu Meet - Rooms
security:
- apiKeyHeader: []
- accessTokenHeader: []
parameters:
- $ref: '../components/parameters/room-id-path.yaml'
requestBody:
$ref: '../components/requestBodies/update-room-status-request.yaml'
responses:
'200':
$ref: '../components/responses/success-update-room-status.yaml'
'202':
$ref: '../components/responses/success-room-schedule-closure.yaml'
'401':
$ref: '../components/responses/unauthorized-error.yaml'
'403':
$ref: '../components/responses/forbidden-error.yaml'
'404':
$ref: '../components/responses/error-room-not-found.yaml'
'422':
$ref: '../components/responses/validation-error.yaml'
'500':
$ref: '../components/responses/internal-server-error.yaml'

View File

@ -14,8 +14,6 @@
description: Operations related to managing global config in OpenVidu Meet
- name: Internal API - Rooms
description: Operations related to managing OpenVidu Meet rooms
- name: Internal API - Participant
description: Operations related to managing participants in OpenVidu Meet rooms
- name: Internal API - Meetings
description: Operations related to managing meetings in OpenVidu Meet rooms
- name: Internal API - Recordings

View File

@ -35,10 +35,9 @@
"start:dev": "NODE_ENV=development concurrently -k -n server,typecheck -c cyan,yellow \"pnpm tsx watch --clear-screen=false --include src ./src/server.ts\" \"pnpm run dev:typecheck\"",
"dev:typecheck": "node ../../scripts/dev/backend-type-checker.mjs",
"package:build": "pnpm run build:prod && pnpm pack",
"test:integration-room-management": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --runInBand --forceExit --testPathPattern \"tests/integration/api/(rooms|meetings|participants)\" --ci --reporters=default --reporters=jest-junit",
"test:integration-room-management": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --runInBand --forceExit --testPathPattern \"tests/integration/api/(rooms|meetings)\" --ci --reporters=default --reporters=jest-junit",
"test:integration-rooms": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --runInBand --forceExit --testPathPattern 'tests/integration/api/rooms' --ci --reporters=default --reporters=jest-junit",
"test:integration-meetings": "node --experimental-vm-modules ../../node_modules/.bin/jest --runInBand --forceExit --testPathPattern \"tests/integration/api/meetings\" --ci --reporters=default --reporters=jest-junit",
"test:integration-participants": "node --experimental-vm-modules ../../node_modules/.bin/jest --runInBand --forceExit --testPathPattern \"tests/integration/api/participants\" --ci --reporters=default --reporters=jest-junit",
"test:integration-webhooks": "node --experimental-vm-modules ../../node_modules/.bin/jest --runInBand --forceExit --testPathPattern \"tests/integration/webhooks\" --ci --reporters=default --reporters=jest-junit",
"test:integration-auth-security": "node --experimental-vm-modules ../../node_modules/.bin/jest --runInBand --forceExit --testPathPattern \"tests/integration/api/(security|auth|api-keys|users)\" --ci --reporters=default --reporters=jest-junit",
"test:integration-security": "node --experimental-vm-modules ../../node_modules/.bin/jest --runInBand --forceExit --testPathPattern \"tests/integration/api/security\" --ci --reporters=default --reporters=jest-junit",

View File

@ -1,14 +0,0 @@
import { ParticipantRole, User } from '@openvidu-meet/typings';
import { ClaimGrants } from 'livekit-server-sdk';
// Override the Express Request type to include a session object with user and token properties
// This will allow controllers to access the user and token information from the request object in a type-safe manner
declare module 'express' {
interface Request {
session?: {
user?: User;
tokenClaims?: ClaimGrants;
participantRole?: ParticipantRole;
};
}
}

View File

@ -14,13 +14,13 @@ import {
ABSStorageProvider,
AnalyticsService,
ApiKeyService,
BaseUrlService,
BlobStorageService,
DistributedEventService,
FrontendEventService,
GCSService,
GCSStorageProvider,
GlobalConfigService,
HttpContextService,
LegacyStorageService,
LiveKitService,
LivekitWebhookService,
@ -30,9 +30,10 @@ import {
MutexService,
OpenViduWebhookService,
ParticipantNameService,
ParticipantService,
RecordingService,
RedisService,
RequestSessionService,
RoomMemberService,
RoomService,
S3KeyBuilder,
S3Service,
@ -69,7 +70,10 @@ export const registerDependencies = () => {
container.bind(DistributedEventService).toSelf().inSingletonScope();
container.bind(MutexService).toSelf().inSingletonScope();
container.bind(TaskSchedulerService).toSelf().inSingletonScope();
container.bind(HttpContextService).toSelf().inSingletonScope();
container.bind(BaseUrlService).toSelf().inSingletonScope();
// RequestSessionService uses AsyncLocalStorage for request isolation
// It's a singleton but provides per-request data isolation automatically
container.bind(RequestSessionService).toSelf().inSingletonScope();
container.bind(MongoDBService).toSelf().inSingletonScope();
container.bind(BaseRepository).toSelf().inSingletonScope();
@ -97,7 +101,7 @@ export const registerDependencies = () => {
container.bind(RecordingService).toSelf().inSingletonScope();
container.bind(RoomService).toSelf().inSingletonScope();
container.bind(ParticipantNameService).toSelf().inSingletonScope();
container.bind(ParticipantService).toSelf().inSingletonScope();
container.bind(RoomMemberService).toSelf().inSingletonScope();
container.bind(OpenViduWebhookService).toSelf().inSingletonScope();
container.bind(LivekitWebhookService).toSelf().inSingletonScope();
container.bind(AnalyticsService).toSelf().inSingletonScope();

View File

@ -5,25 +5,16 @@ export const INTERNAL_CONFIG = {
API_BASE_PATH_V1: '/api/v1',
INTERNAL_API_BASE_PATH_V1: '/internal-api/v1',
// Cookie names
ACCESS_TOKEN_COOKIE_NAME: 'OvMeetAccessToken',
REFRESH_TOKEN_COOKIE_NAME: 'OvMeetRefreshToken',
PARTICIPANT_TOKEN_COOKIE_NAME: 'OvMeetParticipantToken',
RECORDING_TOKEN_COOKIE_NAME: 'OvMeetRecordingToken',
// Headers names
API_KEY_HEADER: 'x-api-key',
ACCESS_TOKEN_HEADER: 'authorization',
REFRESH_TOKEN_HEADER: 'x-refresh-token',
PARTICIPANT_TOKEN_HEADER: 'x-participant-token',
PARTICIPANT_ROLE_HEADER: 'x-participant-role',
RECORDING_TOKEN_HEADER: 'x-recording-token',
ROOM_MEMBER_TOKEN_HEADER: 'x-room-member-token',
// Token expiration times
ACCESS_TOKEN_EXPIRATION: '2h',
REFRESH_TOKEN_EXPIRATION: '1d',
PARTICIPANT_TOKEN_EXPIRATION: '2h',
RECORDING_TOKEN_EXPIRATION: '2h',
ROOM_MEMBER_TOKEN_EXPIRATION: '2h',
// Participant name reservations
PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS: '20', // Maximum number of request by the same name at the same time allowed

View File

@ -1,8 +1,6 @@
import { AuthTransportMode } from '@openvidu-meet/typings';
import { Request, Response } from 'express';
import { ClaimGrants } from 'livekit-server-sdk';
import { container } from '../config/index.js';
import { INTERNAL_CONFIG } from '../config/internal-config.js';
import {
errorInvalidCredentials,
errorInvalidRefreshToken,
@ -12,7 +10,7 @@ import {
rejectRequestFromMeetError
} from '../models/error.model.js';
import { LoggerService, TokenService, UserService } from '../services/index.js';
import { getAuthTransportMode, getCookieOptions, getRefreshToken } from '../utils/index.js';
import { getRefreshToken } from '../utils/index.js';
export const login = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
@ -34,49 +32,19 @@ export const login = async (req: Request, res: Response) => {
const refreshToken = await tokenService.generateRefreshToken(user);
logger.info(`Login succeeded for user '${username}'`);
const transportMode = await getAuthTransportMode();
if (transportMode === AuthTransportMode.HEADER) {
// Send tokens in response body for header mode
return res.status(200).json({
message: `User '${username}' logged in successfully`,
accessToken,
refreshToken
});
} else {
// Send tokens as cookies for cookie mode
res.cookie(
INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME,
accessToken,
getCookieOptions('/', INTERNAL_CONFIG.ACCESS_TOKEN_EXPIRATION)
);
res.cookie(
INTERNAL_CONFIG.REFRESH_TOKEN_COOKIE_NAME,
refreshToken,
getCookieOptions(
`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/auth`,
INTERNAL_CONFIG.REFRESH_TOKEN_EXPIRATION
)
);
return res.status(200).json({ message: `User '${username}' logged in successfully` });
}
return res.status(200).json({
message: `User '${username}' logged in successfully`,
accessToken,
refreshToken
});
} catch (error) {
handleError(res, error, 'generating access and refresh tokens');
}
};
export const logout = async (_req: Request, res: Response) => {
const transportMode = await getAuthTransportMode();
if (transportMode === AuthTransportMode.COOKIE) {
// Clear cookies only in cookie mode
res.clearCookie(INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME);
res.clearCookie(INTERNAL_CONFIG.REFRESH_TOKEN_COOKIE_NAME, {
path: `${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/auth`
});
}
// In header mode, the client is responsible for clearing localStorage
// The client is responsible for clearing tokens from localStorage,
// so just respond with success
return res.status(200).json({ message: 'Logout successful' });
};
@ -118,23 +86,10 @@ export const refreshToken = async (req: Request, res: Response) => {
const accessToken = await tokenService.generateAccessToken(user);
logger.info(`Access token refreshed for user '${username}'`);
const transportMode = await getAuthTransportMode();
if (transportMode === AuthTransportMode.HEADER) {
// Send access token in response body for header mode
return res.status(200).json({
message: `Access token for user '${username}' successfully refreshed`,
accessToken
});
} else {
// Send access token as cookie for cookie mode
res.cookie(
INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME,
accessToken,
getCookieOptions('/', INTERNAL_CONFIG.ACCESS_TOKEN_EXPIRATION)
);
return res.status(200).json({ message: `Access token for user '${username}' successfully refreshed` });
}
return res.status(200).json({
message: `Access token for user '${username}' successfully refreshed`,
accessToken
});
} catch (error) {
handleError(res, error, 'refreshing token');
}

View File

@ -3,7 +3,6 @@ export * from './api-key.controller.js';
export * from './user.controller.js';
export * from './room.controller.js';
export * from './meeting.controller.js';
export * from './participant.controller.js';
export * from './recording.controller.js';
export * from './livekit-webhook.controller.js';
export * from './analytics.controller.js';

View File

@ -1,7 +1,7 @@
import { Request, Response } from 'express';
import { container } from '../config/index.js';
import { handleError } from '../models/error.model.js';
import { LiveKitService, LoggerService, RoomService } from '../services/index.js';
import { LiveKitService, LoggerService, RoomMemberService, RoomService } from '../services/index.js';
export const endMeeting = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
@ -26,3 +26,42 @@ export const endMeeting = async (req: Request, res: Response) => {
handleError(res, error, `ending meeting from room '${roomId}'`);
}
};
export const updateParticipantRole = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomMemberService = container.get(RoomMemberService);
const { roomId, participantIdentity } = req.params;
const { role } = req.body;
try {
logger.verbose(`Changing role of participant '${participantIdentity}' in room '${roomId}' to '${role}'`);
await roomMemberService.updateParticipantRole(roomId, participantIdentity, role);
res.status(200).json({ message: `Participant '${participantIdentity}' role updated to '${role}'` });
} catch (error) {
handleError(res, error, `changing role for participant '${participantIdentity}' in room '${roomId}'`);
}
};
export const kickParticipantFromMeeting = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomService = container.get(RoomService);
const roomMemberService = container.get(RoomMemberService);
const { roomId, participantIdentity } = req.params;
// Check if the room exists
try {
await roomService.getMeetRoom(roomId);
} catch (error) {
return handleError(res, error, `getting room '${roomId}'`);
}
try {
logger.verbose(`Kicking participant '${participantIdentity}' from room '${roomId}'`);
await roomMemberService.kickParticipantFromMeeting(roomId, participantIdentity);
res.status(200).json({
message: `Participant '${participantIdentity}' kicked successfully from meeting in room '${roomId}'`
});
} catch (error) {
handleError(res, error, `kicking participant '${participantIdentity}' from meeting in room '${roomId}'`);
}
};

View File

@ -1,151 +0,0 @@
import {
AuthTransportMode,
OpenViduMeetPermissions,
ParticipantOptions,
ParticipantRole
} from '@openvidu-meet/typings';
import { Request, Response } from 'express';
import { container } from '../config/index.js';
import { INTERNAL_CONFIG } from '../config/internal-config.js';
import {
errorInvalidParticipantToken,
errorParticipantTokenNotPresent,
handleError,
rejectRequestFromMeetError
} from '../models/error.model.js';
import { LoggerService, ParticipantService, RoomService, TokenService } from '../services/index.js';
import { getAuthTransportMode, getCookieOptions, getParticipantToken } from '../utils/index.js';
export const generateParticipantToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const participantService = container.get(ParticipantService);
const tokenService = container.get(TokenService);
const participantOptions: ParticipantOptions = req.body;
const { roomId } = participantOptions;
// Check if there is a previous token (only for cookie mode)
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
// This logic is only used in cookie mode to allow multiple roles across tabs
logger.verbose('Previous participant token found. Extracting roles');
try {
const claims = tokenService.getClaimsIgnoringExpiration(previousToken);
const metadata = participantService.parseMetadata(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}'`);
const token = await participantService.generateOrRefreshParticipantToken(participantOptions, currentRoles);
const authTransportMode = await getAuthTransportMode();
// Send participant token as cookie for cookie mode
if (authTransportMode === AuthTransportMode.COOKIE) {
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}'`);
}
};
export const refreshParticipantToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const tokenService = container.get(TokenService);
const participantService = container.get(ParticipantService);
// Check if there is a previous token
const previousToken = await getParticipantToken(req);
if (!previousToken) {
logger.verbose('No previous participant token found. Cannot refresh.');
const error = errorParticipantTokenNotPresent();
return rejectRequestFromMeetError(res, error);
}
// Extract roles from the previous token
let currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] = [];
try {
const claims = tokenService.getClaimsIgnoringExpiration(previousToken);
const metadata = participantService.parseMetadata(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 { roomId } = participantOptions;
try {
logger.verbose(`Refreshing participant token for room '${roomId}'`);
const token = await participantService.generateOrRefreshParticipantToken(
participantOptions,
currentRoles,
true
);
const authTransportMode = await getAuthTransportMode();
// Send participant token as cookie for cookie mode
if (authTransportMode === AuthTransportMode.COOKIE) {
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}'`);
}
};
export const updateParticipantRole = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const participantService = container.get(ParticipantService);
const { roomId, participantIdentity } = req.params;
const { role } = req.body;
try {
logger.verbose(`Changing role of participant '${participantIdentity}' in room '${roomId}' to '${role}'`);
await participantService.updateParticipantRole(roomId, participantIdentity, role);
res.status(200).json({ message: `Participant '${participantIdentity}' role updated to '${role}'` });
} catch (error) {
handleError(res, error, `changing role for participant '${participantIdentity}' in room '${roomId}'`);
}
};
export const kickParticipant = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomService = container.get(RoomService);
const participantService = container.get(ParticipantService);
const { roomId, participantIdentity } = req.params;
// Check if the room exists
try {
await roomService.getMeetRoom(roomId);
} catch (error) {
return handleError(res, error, `getting room '${roomId}'`);
}
try {
logger.verbose(`Kicking participant '${participantIdentity}' from room '${roomId}'`);
await participantService.kickParticipant(roomId, participantIdentity);
res.status(200).json({
message: `Participant '${participantIdentity}' kicked successfully from room '${roomId}'`
});
} catch (error) {
handleError(res, error, `kicking participant '${participantIdentity}' from room '${roomId}'`);
}
};

View File

@ -12,7 +12,7 @@ import {
rejectRequestFromMeetError
} from '../models/error.model.js';
import { RecordingRepository } from '../repositories/index.js';
import { LoggerService, RecordingService } from '../services/index.js';
import { LoggerService, RecordingService, RequestSessionService } from '../services/index.js';
import { getBaseUrl } from '../utils/index.js';
export const startRecording = async (req: Request, res: Response) => {
@ -37,13 +37,13 @@ export const startRecording = async (req: Request, res: Response) => {
export const getRecordings = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService);
const requestSessionService = container.get(RequestSessionService);
const queryParams = req.query;
// If recording token is present, retrieve only recordings for the room associated with the token
const payload = req.session?.tokenClaims;
// If room member token is present, retrieve only recordings for the room associated with the token
const roomId = requestSessionService.getRoomIdFromToken();
if (payload && payload.video) {
const roomId = payload.video.room;
if (roomId) {
queryParams.roomId = roomId;
logger.info(`Getting recordings for room '${roomId}'`);
} else {
@ -70,20 +70,17 @@ export const getRecordings = async (req: Request, res: Response) => {
export const bulkDeleteRecordings = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService);
const requestSessionService = container.get(RequestSessionService);
const { recordingIds } = req.query;
// If recording token is present, delete only recordings for the room associated with the token
const payload = req.session?.tokenClaims;
let roomId: string | undefined;
if (payload && payload.video) {
roomId = payload.video.room;
}
logger.info(`Deleting recordings: ${recordingIds}`);
try {
const recordingIdsArray = (recordingIds as string).split(',');
// If room member token is present, delete only recordings for the room associated with the token
const roomId = requestSessionService.getRoomIdFromToken();
const { deleted, failed } = await recordingService.bulkDeleteRecordings(recordingIdsArray, roomId);
// All recordings were successfully deleted
@ -257,21 +254,17 @@ export const getRecordingUrl = async (req: Request, res: Response) => {
export const downloadRecordingsZip = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService);
const requestSessionService = container.get(RequestSessionService);
const recordingIds = req.query.recordingIds as string;
const recordingIdsArray = (recordingIds as string).split(',');
// If recording token is present, download only recordings for the room associated with the token
const payload = req.session?.tokenClaims;
let roomId: string | undefined;
if (payload && payload.video) {
roomId = payload.video.room;
}
// Filter recording IDs if a room ID is provided
let validRecordingIds = recordingIdsArray;
// If room member token is present, download only recordings for the room associated with the token
const roomId = requestSessionService.getRoomIdFromToken();
if (roomId) {
validRecordingIds = recordingIdsArray.filter((recordingId) => {
const { roomId: recRoomId } = RecordingHelper.extractInfoFromRecordingId(recordingId);

View File

@ -1,19 +1,19 @@
import {
AuthTransportMode,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomDeletionSuccessCode,
MeetRoomFilters,
MeetRoomOptions,
MeetRoomRoleAndPermissions,
ParticipantRole
MeetRoomMemberRole,
MeetRoomMemberRoleAndPermissions,
MeetRoomMemberTokenOptions,
MeetRoomOptions
} from '@openvidu-meet/typings';
import { Request, Response } from 'express';
import { container } from '../config/index.js';
import { INTERNAL_CONFIG } from '../config/internal-config.js';
import { handleError } from '../models/error.model.js';
import { LoggerService, ParticipantService, RoomService } from '../services/index.js';
import { getAuthTransportMode, getBaseUrl, getCookieOptions } from '../utils/index.js';
import { LoggerService, RoomMemberService, RoomService } from '../services/index.js';
import { getBaseUrl } from '../utils/index.js';
export const createRoom = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
@ -52,13 +52,12 @@ export const getRoom = async (req: Request, res: Response) => {
const { roomId } = req.params;
const fields = req.query.fields as string | undefined;
const role = req.session?.participantRole;
try {
logger.verbose(`Getting room '${roomId}'`);
const roomService = container.get(RoomService);
const room = await roomService.getMeetRoom(roomId, fields, role);
const room = await roomService.getMeetRoom(roomId, fields);
return res.status(200).json(room);
} catch (error) {
@ -183,37 +182,26 @@ export const updateRoomStatus = async (req: Request, res: Response) => {
}
};
export const generateRecordingToken = async (req: Request, res: Response) => {
export const generateRoomMemberToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomService = container.get(RoomService);
const { roomId } = req.params;
const { secret } = req.body;
const roomMemberTokenService = container.get(RoomMemberService);
logger.verbose(`Generating recording token for room '${roomId}'`);
const { roomId } = req.params;
const tokenOptions: MeetRoomMemberTokenOptions = req.body;
try {
const token = await roomService.generateRecordingToken(roomId, secret);
const authTransportMode = await getAuthTransportMode();
// Send recording token as cookie for cookie mode
if (authTransportMode === AuthTransportMode.COOKIE) {
res.cookie(
INTERNAL_CONFIG.RECORDING_TOKEN_COOKIE_NAME,
token,
getCookieOptions('/', INTERNAL_CONFIG.RECORDING_TOKEN_EXPIRATION)
);
}
logger.verbose(`Generating room member token for room '${roomId}'`);
const token = await roomMemberTokenService.generateOrRefreshRoomMemberToken(roomId, tokenOptions);
return res.status(200).json({ token });
} catch (error) {
handleError(res, error, `generating recording token for room '${roomId}'`);
handleError(res, error, `generating room member token for room '${roomId}'`);
}
};
export const getRoomRolesAndPermissions = async (req: Request, res: Response) => {
export const getRoomMemberRolesAndPermissions = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomService = container.get(RoomService);
const participantService = container.get(ParticipantService);
const roomMemberService = container.get(RoomMemberService);
const { roomId } = req.params;
@ -224,41 +212,42 @@ export const getRoomRolesAndPermissions = async (req: Request, res: Response) =>
return handleError(res, error, `getting room '${roomId}'`);
}
logger.verbose(`Getting roles and associated permissions for room '${roomId}'`);
const moderatorPermissions = participantService.getParticipantPermissions(roomId, ParticipantRole.MODERATOR);
const speakerPermissions = participantService.getParticipantPermissions(roomId, ParticipantRole.SPEAKER);
logger.verbose(`Getting room member roles and associated permissions for room '${roomId}'`);
const moderatorPermissions = await roomMemberService.getRoomMemberPermissions(roomId, MeetRoomMemberRole.MODERATOR);
const speakerPermissions = await roomMemberService.getRoomMemberPermissions(roomId, MeetRoomMemberRole.SPEAKER);
const rolesAndPermissions = [
const rolesAndPermissions: MeetRoomMemberRoleAndPermissions[] = [
{
role: ParticipantRole.MODERATOR,
role: MeetRoomMemberRole.MODERATOR,
permissions: moderatorPermissions
},
{
role: ParticipantRole.SPEAKER,
role: MeetRoomMemberRole.SPEAKER,
permissions: speakerPermissions
}
];
res.status(200).json(rolesAndPermissions);
};
export const getRoomRoleAndPermissions = async (req: Request, res: Response) => {
export const getRoomMemberRoleAndPermissions = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const roomService = container.get(RoomService);
const participantService = container.get(ParticipantService);
const roomMemberService = container.get(RoomMemberService);
const { roomId, secret } = req.params;
try {
logger.verbose(`Getting room role and associated permissions for room '${roomId}' and secret '${secret}'`);
logger.verbose(
`Getting room member role and associated permissions for room '${roomId}' and secret '${secret}'`
);
const role = await roomService.getRoomRoleBySecret(roomId, secret);
const permissions = participantService.getParticipantPermissions(roomId, role);
const roleAndPermissions: MeetRoomRoleAndPermissions = {
const role = await roomMemberService.getRoomMemberRoleBySecret(roomId, secret);
const permissions = await roomMemberService.getRoomMemberPermissions(roomId, role);
const roleAndPermissions: MeetRoomMemberRoleAndPermissions = {
role,
permissions
};
return res.status(200).json(roleAndPermissions);
} catch (error) {
handleError(res, error, `getting room role and permissions for room '${roomId}' and secret '${secret}'`);
handleError(res, error, `getting room member role and permissions for room '${roomId}' and secret '${secret}'`);
}
};

View File

@ -1,10 +1,11 @@
import { Request, Response } from 'express';
import { container } from '../config/index.js';
import { errorUnauthorized, handleError, rejectRequestFromMeetError } from '../models/error.model.js';
import { UserService } from '../services/index.js';
import { RequestSessionService, UserService } from '../services/index.js';
export const getProfile = (req: Request, res: Response) => {
const user = req.session?.user;
const requestSessionService = container.get(RequestSessionService);
const user = requestSessionService.getUser();
if (!user) {
const error = errorUnauthorized();
@ -17,7 +18,8 @@ export const getProfile = (req: Request, res: Response) => {
};
export const changePassword = async (req: Request, res: Response) => {
const user = req.session?.user;
const requestSessionService = container.get(RequestSessionService);
const user = requestSessionService.getUser();
if (!user) {
const error = errorUnauthorized();

View File

@ -1,4 +1,4 @@
import { MeetTokenMetadata, OpenViduMeetPermissions, ParticipantRole, User, UserRole } from '@openvidu-meet/typings';
import { LiveKitPermissions, MeetUser, MeetUserRole } from '@openvidu-meet/typings';
import { NextFunction, Request, RequestHandler, Response } from 'express';
import rateLimit from 'express-rate-limit';
import { ClaimGrants } from 'livekit-server-sdk';
@ -8,9 +8,7 @@ import { INTERNAL_CONFIG } from '../config/internal-config.js';
import {
errorInsufficientPermissions,
errorInvalidApiKey,
errorInvalidParticipantRole,
errorInvalidParticipantToken,
errorInvalidRecordingToken,
errorInvalidRoomMemberToken,
errorInvalidToken,
errorInvalidTokenSubject,
errorUnauthorized,
@ -20,12 +18,12 @@ import {
import {
ApiKeyService,
LoggerService,
ParticipantService,
RoomService,
RequestSessionService,
RoomMemberService,
TokenService,
UserService
} from '../services/index.js';
import { getAccessToken, getParticipantToken, getRecordingToken } from '../utils/index.js';
import { getAccessToken, getRoomMemberToken } from '../utils/index.js';
/**
* Interface for authentication validators.
@ -82,15 +80,15 @@ export const withAuth = (...validators: AuthValidator[]): RequestHandler => {
*
* @param roles One or more roles that are allowed to access the resource
*/
export const tokenAndRoleValidator = (...roles: UserRole[]): AuthValidator => {
export const tokenAndRoleValidator = (...roles: MeetUserRole[]): AuthValidator => {
return {
async isPresent(req: Request): Promise<boolean> {
const token = await getAccessToken(req);
const token = getAccessToken(req);
return !!token;
},
async validate(req: Request): Promise<void> {
const token = await getAccessToken(req);
const token = getAccessToken(req);
if (!token) {
throw errorUnauthorized();
@ -120,104 +118,61 @@ export const tokenAndRoleValidator = (...roles: UserRole[]): AuthValidator => {
throw errorInsufficientPermissions();
}
req.session = req.session || {};
req.session.user = user;
const requestSessionService = container.get(RequestSessionService);
requestSessionService.setUser(user);
}
};
};
/**
* Participant token validator for room access.
* Validates participant tokens and checks role permissions.
* Room member token validator for room access.
* Validates room member tokens and checks role permissions.
*/
export const participantTokenValidator: AuthValidator = {
export const roomMemberTokenValidator: AuthValidator = {
async isPresent(req: Request): Promise<boolean> {
const token = await getParticipantToken(req);
const token = getRoomMemberToken(req);
return !!token;
},
async validate(req: Request): Promise<void> {
const token = await getParticipantToken(req);
await validateTokenAndSetSession(req, token);
const token = getRoomMemberToken(req);
// Check if the participant role is provided in the request headers
// This is required to distinguish roles when multiple are present in the token
const participantRole = req.headers[INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER];
const allRoles = [ParticipantRole.MODERATOR, ParticipantRole.SPEAKER];
if (!participantRole || !allRoles.includes(participantRole as ParticipantRole)) {
throw errorInvalidParticipantRole();
if (!token) {
throw errorUnauthorized();
}
// Check that the specified role is present in the token claims
let metadata: MeetTokenMetadata;
let tokenMetadata: string | undefined;
let livekitPermissions: LiveKitPermissions | undefined;
try {
const participantService = container.get(ParticipantService);
metadata = participantService.parseMetadata(req.session?.tokenClaims?.metadata || '{}');
const tokenService = container.get(TokenService);
({ metadata: tokenMetadata, video: livekitPermissions } = await tokenService.verifyToken(token));
if (!tokenMetadata || !livekitPermissions) {
throw new Error('Missing required token claims');
}
} catch (error) {
const logger = container.get(LoggerService);
logger.error('Invalid participant token:', error);
throw errorInvalidParticipantToken();
throw errorInvalidToken();
}
const roles = metadata.roles;
const hasRole = roles.some(
(r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === participantRole
);
const requestSessionService = container.get(RequestSessionService);
if (!hasRole) {
throw errorInsufficientPermissions();
}
// Set the participant role in the session
req.session!.participantRole = participantRole as ParticipantRole;
}
};
/**
* Recording token validator for recording access.
* Validates recording tokens with specific metadata.
*/
export const recordingTokenValidator: AuthValidator = {
async isPresent(req: Request): Promise<boolean> {
const token = await getRecordingToken(req);
return !!token;
},
async validate(req: Request): Promise<void> {
const token = await getRecordingToken(req);
await validateTokenAndSetSession(req, token);
// Validate the recording token metadata
// Validate the room member token metadata and extract role and permissions
try {
const roomService = container.get(RoomService);
roomService.parseRecordingTokenMetadata(req.session?.tokenClaims?.metadata || '{}');
const roomMemberService = container.get(RoomMemberService);
const { role, permissions: meetPermissions } =
roomMemberService.parseRoomMemberTokenMetadata(tokenMetadata);
requestSessionService.setRoomMemberTokenInfo(role, meetPermissions, livekitPermissions);
} catch (error) {
const logger = container.get(LoggerService);
logger.error('Invalid recording token:', error);
throw errorInvalidRecordingToken();
logger.error('Invalid room member token:', error);
throw errorInvalidRoomMemberToken();
}
}
};
const validateTokenAndSetSession = async (req: Request, token: string | undefined) => {
if (!token) {
throw errorUnauthorized();
}
const tokenService = container.get(TokenService);
let payload: ClaimGrants;
try {
payload = await tokenService.verifyToken(token);
// Set authenticated user if present, otherwise anonymous
const user = await getAuthenticatedUserOrAnonymous(req);
req.session = req.session || {};
req.session.tokenClaims = payload;
req.session.user = user;
} catch (error) {
throw errorInvalidToken();
requestSessionService.setUser(user);
}
};
@ -248,8 +203,8 @@ export const apiKeyValidator: AuthValidator = {
const userService = container.get(UserService);
const apiUser = userService.getApiUser();
req.session = req.session || {};
req.session.user = apiUser;
const requestSessionService = container.get(RequestSessionService);
requestSessionService.setUser(apiUser);
}
};
@ -266,18 +221,18 @@ export const allowAnonymous: AuthValidator = {
async validate(req: Request): Promise<void> {
const user = await getAuthenticatedUserOrAnonymous(req);
req.session = req.session || {};
req.session.user = user;
const requestSessionService = container.get(RequestSessionService);
requestSessionService.setUser(user);
}
};
// Return the authenticated user if available, otherwise return an anonymous user
const getAuthenticatedUserOrAnonymous = async (req: Request): Promise<User> => {
const getAuthenticatedUserOrAnonymous = async (req: Request): Promise<MeetUser> => {
const userService = container.get(UserService);
let user: User | null = null;
let user: MeetUser | null = null;
// Check if there is a user already authenticated
const token = await getAccessToken(req);
const token = getAccessToken(req);
if (token) {
try {

View File

@ -0,0 +1,14 @@
import { NextFunction, Request, Response } from 'express';
import { container } from '../config/dependency-injector.config.js';
import { BaseUrlService } from '../services/index.js';
export const setBaseUrlMiddleware = (req: Request, _res: Response, next: NextFunction) => {
if (req.path === '/livekit/webhook') {
// Skip setting base URL for LiveKit webhooks
return next();
}
const baseUrlService = container.get(BaseUrlService);
baseUrlService.setBaseUrlFromRequest(req);
next();
};

View File

@ -1,14 +0,0 @@
import { NextFunction, Request, Response } from 'express';
import { container } from '../config/dependency-injector.config.js';
import { HttpContextService } from '../services/index.js';
export const httpContextMiddleware = (req: Request, _res: Response, next: NextFunction) => {
if (req.path === '/livekit/webhook') {
// Skip setting context for LiveKit webhooks
return next();
}
const httpContextService = container.get(HttpContextService);
httpContextService.setContext(req);
next();
};

View File

@ -1,5 +1,6 @@
export * from './content-type.middleware.js';
export * from './http-context.middleware.js';
export * from './base-url.middleware.js';
export * from './request-context.middleware.js';
export * from './auth.middleware.js';
export * from './room.middleware.js';
export * from './participant.middleware.js';

View File

@ -1,28 +1,30 @@
import { AuthMode, ParticipantOptions, ParticipantRole, UserRole } from '@openvidu-meet/typings';
import { AuthMode, MeetRoomMemberRole, MeetRoomMemberTokenOptions, MeetUserRole } from '@openvidu-meet/typings';
import { NextFunction, Request, Response } from 'express';
import { container } from '../config/index.js';
import { errorInsufficientPermissions, handleError, rejectRequestFromMeetError } from '../models/error.model.js';
import { GlobalConfigService, RoomService } from '../services/index.js';
import { GlobalConfigService, RequestSessionService, RoomMemberService } from '../services/index.js';
import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middleware.js';
/**
* Middleware to configure authentication based on participant role and authentication mode for entering a room.
* Middleware to configure authentication for generating token to access room and its resources
* based on room member role and authentication mode.
*
* - If the authentication mode is MODERATORS_ONLY and the participant role is MODERATOR, configure user authentication.
* - If the authentication mode is MODERATORS_ONLY and the room member role is MODERATOR, configure user authentication.
* - If the authentication mode is ALL_USERS, configure user authentication.
* - Otherwise, allow anonymous access.
*/
export const configureParticipantTokenAuth = async (req: Request, res: Response, next: NextFunction) => {
export const configureRoomMemberTokenAuth = async (req: Request, res: Response, next: NextFunction) => {
const configService = container.get(GlobalConfigService);
const roomService = container.get(RoomService);
const roomMemberService = container.get(RoomMemberService);
let role: ParticipantRole;
let role: MeetRoomMemberRole;
try {
const { roomId, secret } = req.body as ParticipantOptions;
role = await roomService.getRoomRoleBySecret(roomId, secret);
const { roomId } = req.params;
const { secret } = req.body as MeetRoomMemberTokenOptions;
role = await roomMemberService.getRoomMemberRoleBySecret(roomId, secret);
} catch (error) {
return handleError(res, error, 'getting room role by secret');
return handleError(res, error, 'getting room member role by secret');
}
let authModeToAccessRoom: AuthMode;
@ -40,11 +42,11 @@ export const configureParticipantTokenAuth = async (req: Request, res: Response,
authValidators.push(allowAnonymous);
} else {
const isModeratorsOnlyMode =
authModeToAccessRoom === AuthMode.MODERATORS_ONLY && role === ParticipantRole.MODERATOR;
authModeToAccessRoom === AuthMode.MODERATORS_ONLY && role === MeetRoomMemberRole.MODERATOR;
const isAllUsersMode = authModeToAccessRoom === AuthMode.ALL_USERS;
if (isModeratorsOnlyMode || isAllUsersMode) {
authValidators.push(tokenAndRoleValidator(UserRole.USER));
authValidators.push(tokenAndRoleValidator(MeetUserRole.USER));
} else {
authValidators.push(allowAnonymous);
}
@ -55,17 +57,17 @@ export const configureParticipantTokenAuth = async (req: Request, res: Response,
export const withModeratorPermissions = async (req: Request, res: Response, next: NextFunction) => {
const { roomId } = req.params;
const payload = req.session?.tokenClaims;
const role = req.session?.participantRole;
if (!payload || !role) {
const requestSessionService = container.get(RequestSessionService);
const tokenRoomId = requestSessionService.getRoomIdFromToken();
const role = requestSessionService.getRoomMemberRole();
if (!tokenRoomId || !role) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
const sameRoom = payload.video?.room === roomId;
if (!sameRoom || role !== ParticipantRole.MODERATOR) {
if (tokenRoomId !== roomId || role !== MeetRoomMemberRole.MODERATOR) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
@ -75,16 +77,11 @@ export const withModeratorPermissions = async (req: Request, res: Response, next
export const checkParticipantFromSameRoom = async (req: Request, res: Response, next: NextFunction) => {
const { roomId } = req.params;
const payload = req.session?.tokenClaims;
if (!payload) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
const requestSessionService = container.get(RequestSessionService);
const tokenRoomId = requestSessionService.getRoomIdFromToken();
const sameRoom = payload.video?.room === roomId;
if (!sameRoom) {
if (!tokenRoomId || tokenRoomId !== roomId) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}

View File

@ -1,4 +1,4 @@
import { MeetRoom, UserRole } from '@openvidu-meet/typings';
import { MeetRoom, MeetUserRole } from '@openvidu-meet/typings';
import { NextFunction, Request, Response } from 'express';
import { container } from '../config/index.js';
import { RecordingHelper } from '../helpers/index.js';
@ -11,11 +11,11 @@ import {
rejectRequestFromMeetError
} from '../models/error.model.js';
import { RecordingRepository } from '../repositories/index.js';
import { LoggerService, ParticipantService, RoomService } from '../services/index.js';
import { LoggerService, RequestSessionService, RoomService } from '../services/index.js';
import {
allowAnonymous,
apiKeyValidator,
recordingTokenValidator,
roomMemberTokenValidator,
tokenAndRoleValidator,
withAuth
} from './auth.middleware.js';
@ -42,22 +42,17 @@ export const withRecordingEnabled = async (req: Request, res: Response, next: Ne
export const withCanRecordPermission = async (req: Request, res: Response, next: NextFunction) => {
const roomId = extractRoomIdFromRequest(req);
const payload = req.session?.tokenClaims;
const role = req.session?.participantRole;
if (!payload || !role) {
const requestSessionService = container.get(RequestSessionService);
const tokenRoomId = requestSessionService.getRoomIdFromToken();
const permissions = requestSessionService.getRoomMemberMeetPermissions();
if (!tokenRoomId || !permissions) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
const participantService = container.get(ParticipantService);
const metadata = participantService.parseMetadata(payload.metadata || '{}');
const sameRoom = payload.video?.room === roomId;
const permissions = metadata.roles.find((r) => r.role === role)?.permissions;
const canRecord = permissions?.canRecord;
if (!sameRoom || !canRecord) {
if (tokenRoomId !== roomId || !permissions.canRecord) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
@ -67,7 +62,9 @@ export const withCanRecordPermission = async (req: Request, res: Response, next:
export const withCanRetrieveRecordingsPermission = async (req: Request, res: Response, next: NextFunction) => {
const roomId = extractRoomIdFromRequest(req);
const payload = req.session?.tokenClaims;
const requestSessionService = container.get(RequestSessionService);
const tokenRoomId = requestSessionService.getRoomIdFromToken();
/**
* If there is no token, the user is allowed to access the resource because one of the following reasons:
@ -77,17 +74,20 @@ export const withCanRetrieveRecordingsPermission = async (req: Request, res: Res
* - The user is anonymous and is using the public access secret.
* - The user is using the private access secret and is authenticated.
*/
if (!payload) {
if (!tokenRoomId) {
return next();
}
const roomService = container.get(RoomService);
const metadata = roomService.parseRecordingTokenMetadata(payload.metadata || '{}');
const permissions = requestSessionService.getRoomMemberMeetPermissions();
const sameRoom = roomId ? payload.video?.room === roomId : true;
const canRetrieveRecordings = metadata.recordingPermissions.canRetrieveRecordings;
if (!permissions) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
if (!sameRoom || !canRetrieveRecordings) {
const sameRoom = roomId ? tokenRoomId === roomId : true;
if (!sameRoom || !permissions.canRetrieveRecordings) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
@ -97,21 +97,26 @@ export const withCanRetrieveRecordingsPermission = async (req: Request, res: Res
export const withCanDeleteRecordingsPermission = async (req: Request, res: Response, next: NextFunction) => {
const roomId = extractRoomIdFromRequest(req);
const payload = req.session?.tokenClaims;
const requestSessionService = container.get(RequestSessionService);
const tokenRoomId = requestSessionService.getRoomIdFromToken();
// If there is no token, the user is admin or it is invoked using the API key
// In this case, the user is allowed to access the resource
if (!payload) {
if (!tokenRoomId) {
return next();
}
const roomService = container.get(RoomService);
const metadata = roomService.parseRecordingTokenMetadata(payload.metadata || '{}');
const permissions = requestSessionService.getRoomMemberMeetPermissions();
const sameRoom = roomId ? payload.video?.room === roomId : true;
const canDeleteRecordings = metadata.recordingPermissions.canDeleteRecordings;
if (!permissions) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
if (!sameRoom || !canDeleteRecordings) {
const sameRoom = roomId ? tokenRoomId === roomId : true;
if (!sameRoom || !permissions.canDeleteRecordings) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
@ -123,7 +128,7 @@ export const withCanDeleteRecordingsPermission = async (req: Request, res: Respo
* Middleware to configure authentication for retrieving recording based on the provided secret.
*
* - If a valid secret is provided in the query, access is granted according to the secret type.
* - If no secret is provided, the default authentication logic is applied, i.e., API key, admin and recording token access.
* - If no secret is provided, the default authentication logic is applied, i.e., API key, admin and room member token access.
*/
export const configureRecordingAuth = async (req: Request, res: Response, next: NextFunction) => {
const secret = req.query.secret as string;
@ -151,7 +156,7 @@ export const configureRecordingAuth = async (req: Request, res: Response, next:
break;
case recordingSecrets.privateAccessSecret:
// Private access secret requires authentication with user role
authValidators.push(tokenAndRoleValidator(UserRole.USER));
authValidators.push(tokenAndRoleValidator(MeetUserRole.USER));
break;
default:
// Invalid secret provided
@ -165,8 +170,8 @@ export const configureRecordingAuth = async (req: Request, res: Response, next:
}
// If no secret is provided, we proceed with the default authentication logic.
// This will allow API key, admin and recording token access.
const authValidators = [apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator];
// This will allow API key, admin and room member token access.
const authValidators = [apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN), roomMemberTokenValidator];
return withAuth(...authValidators)(req, res, next);
};

View File

@ -0,0 +1,28 @@
import { NextFunction, Request, Response } from 'express';
import { container } from '../config/index.js';
import { RequestSessionService } from '../services/index.js';
/**
* Middleware that initializes the AsyncLocalStorage context for each HTTP request.
*
* This middleware MUST be registered before any other middleware or route handler
* that needs to access the RequestSessionService. It creates an isolated context
* for the entire request lifecycle, ensuring that data stored in RequestSessionService
* is unique to each request and doesn't leak between concurrent requests.
*
* How it works:
* 1. Gets the singleton RequestSessionService from the container
* 2. Calls requestSessionService.run() which creates a new AsyncLocalStorage context
* 3. All subsequent code (middlewares, controllers) executed within this context
* will have access to the same isolated storage
* 4. The context is automatically cleaned up when the request completes
*/
export const initRequestContext = (_req: Request, _res: Response, next: NextFunction) => {
const requestSessionService = container.get(RequestSessionService);
// Wrap the rest of the request handling in the AsyncLocalStorage context
// All subsequent middlewares and route handlers will execute within this context
requestSessionService.run(() => {
next();
});
};

View File

@ -1,7 +1,6 @@
import {
AuthenticationConfig,
AuthMode,
AuthTransportMode,
AuthType,
SecurityConfig,
SingleUserAuth,
@ -40,8 +39,6 @@ const WebhookTestSchema = z.object({
.regex(/^https?:\/\//, { message: 'URL must start with http:// or https://' })
});
const AuthTransportModeSchema: z.ZodType<AuthTransportMode> = z.nativeEnum(AuthTransportMode);
const AuthModeSchema: z.ZodType<AuthMode> = z.nativeEnum(AuthMode);
const AuthTypeSchema: z.ZodType<AuthType> = z.nativeEnum(AuthType);
@ -54,7 +51,6 @@ const ValidAuthMethodSchema: z.ZodType<ValidAuthMethod> = SingleUserAuthSchema;
const AuthenticationConfigSchema: z.ZodType<AuthenticationConfig> = z.object({
authMethod: ValidAuthMethodSchema,
authTransportMode: AuthTransportModeSchema,
authModeToAccessRoom: AuthModeSchema
});

View File

@ -1,53 +1,26 @@
import {
MeetTokenMetadata,
OpenViduMeetPermissions,
ParticipantOptions,
ParticipantRole
} from '@openvidu-meet/typings';
import { MeetPermissions, MeetRoomMemberRole, MeetRoomMemberTokenMetadata } from '@openvidu-meet/typings';
import { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { rejectUnprocessableRequest } from '../../models/error.model.js';
import { nonEmptySanitizedRoomId } from './room-validator.middleware.js';
const ParticipantTokenRequestSchema: z.ZodType<ParticipantOptions> = z.object({
roomId: nonEmptySanitizedRoomId('roomId'),
secret: z.string().nonempty('Secret is required'),
participantName: z.string().optional(),
participantIdentity: z.string().optional()
});
const UpdateParticipantRequestSchema = z.object({
role: z.nativeEnum(ParticipantRole)
role: z.nativeEnum(MeetRoomMemberRole)
});
const OpenViduMeetPermissionsSchema: z.ZodType<OpenViduMeetPermissions> = z.object({
const MeetPermissionsSchema: z.ZodType<MeetPermissions> = z.object({
canRecord: z.boolean(),
canRetrieveRecordings: z.boolean(),
canDeleteRecordings: z.boolean(),
canChat: z.boolean(),
canChangeVirtualBackground: z.boolean()
});
const MeetTokenMetadataSchema: z.ZodType<MeetTokenMetadata> = z.object({
const RoomMemberTokenMetadataSchema: z.ZodType<MeetRoomMemberTokenMetadata> = z.object({
livekitUrl: z.string().url('LiveKit URL must be a valid URL'),
roles: z.array(
z.object({
role: z.nativeEnum(ParticipantRole),
permissions: OpenViduMeetPermissionsSchema
})
),
selectedRole: z.nativeEnum(ParticipantRole)
role: z.nativeEnum(MeetRoomMemberRole),
permissions: MeetPermissionsSchema
});
export const validateParticipantTokenRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = ParticipantTokenRequestSchema.safeParse(req.body);
if (!success) {
return rejectUnprocessableRequest(res, error);
}
req.body = data;
next();
};
export const validateUpdateParticipantRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = UpdateParticipantRequestSchema.safeParse(req.body);
@ -59,8 +32,8 @@ export const validateUpdateParticipantRequest = (req: Request, res: Response, ne
next();
};
export const validateMeetTokenMetadata = (metadata: unknown): MeetTokenMetadata => {
const { success, error, data } = MeetTokenMetadataSchema.safeParse(metadata);
export const validateRoomMemberTokenMetadata = (metadata: unknown): MeetRoomMemberTokenMetadata => {
const { success, error, data } = RoomMemberTokenMetadataSchema.safeParse(metadata);
if (!success) {
throw new Error(`Invalid metadata: ${error.message}`);

View File

@ -9,13 +9,12 @@ import {
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomFilters,
MeetRoomMemberTokenOptions,
MeetRoomOptions,
MeetRoomStatus,
MeetRoomTheme,
MeetRoomThemeMode,
MeetVirtualBackgroundConfig,
ParticipantRole,
RecordingPermissions
MeetVirtualBackgroundConfig
} from '@openvidu-meet/typings';
import { NextFunction, Request, Response } from 'express';
import ms from 'ms';
@ -274,22 +273,26 @@ const UpdateRoomConfigSchema = z.object({
});
const UpdateRoomStatusSchema = z.object({
status: z.nativeEnum(MeetRoomStatus)
status: z.enum([MeetRoomStatus.OPEN, MeetRoomStatus.CLOSED])
});
const RecordingTokenRequestSchema = z.object({
secret: z.string().nonempty('Secret is required')
});
const RecordingPermissionsSchema: z.ZodType<RecordingPermissions> = z.object({
canRetrieveRecordings: z.boolean(),
canDeleteRecordings: z.boolean()
});
const RecordingTokenMetadataSchema = z.object({
role: z.nativeEnum(ParticipantRole),
recordingPermissions: RecordingPermissionsSchema
});
const RoomMemberTokenRequestSchema: z.ZodType<MeetRoomMemberTokenOptions> = z
.object({
secret: z.string().nonempty('Secret is required'),
grantJoinMeetingPermission: z.boolean().optional().default(false),
participantName: z.string().optional(),
participantIdentity: z.string().optional()
})
.refine(
(data) => {
// If grantJoinMeetingPermission is true, participantName must be provided
return !data.grantJoinMeetingPermission || data.participantName;
},
{
message: 'participantName is required when grantJoinMeetingPermission is true',
path: ['participantName']
}
);
export const withValidRoomOptions = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = RoomRequestOptionsSchema.safeParse(req.body);
@ -380,8 +383,8 @@ export const withValidRoomStatus = (req: Request, res: Response, next: NextFunct
next();
};
export const withValidRoomSecret = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = RecordingTokenRequestSchema.safeParse(req.body);
export const withValidRoomMemberTokenRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = RoomMemberTokenRequestSchema.safeParse(req.body);
if (!success) {
return rejectUnprocessableRequest(res, error);
@ -390,13 +393,3 @@ export const withValidRoomSecret = (req: Request, res: Response, next: NextFunct
req.body = data;
next();
};
export const validateRecordingTokenMetadata = (metadata: unknown) => {
const { success, error, data } = RecordingTokenMetadataSchema.safeParse(metadata);
if (!success) {
throw new Error(`Invalid metadata: ${error.message}`);
}
return data;
};

View File

@ -1,13 +1,7 @@
import { AuthMode, MeetRecordingAccess, ParticipantRole, UserRole } from '@openvidu-meet/typings';
import { NextFunction, Request, Response } from 'express';
import { container } from '../config/index.js';
import {
errorInsufficientPermissions,
handleError,
rejectRequestFromMeetError
} from '../models/error.model.js';
import { GlobalConfigService, RoomService } from '../services/index.js';
import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middleware.js';
import { errorInsufficientPermissions, rejectRequestFromMeetError } from '../models/error.model.js';
import { RequestSessionService } from '../services/index.js';
/**
* Middleware that configures authorization for accessing a specific room.
@ -18,80 +12,21 @@ import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middlewa
*/
export const configureRoomAuthorization = async (req: Request, res: Response, next: NextFunction) => {
const roomId = req.params.roomId as string;
const payload = req.session?.tokenClaims;
const requestSessionService = container.get(RequestSessionService);
const tokenRoomId = requestSessionService.getRoomIdFromToken();
// If there is no token, the user is admin or it is invoked using the API key
// In this case, the user is allowed to access the resource
if (!payload) {
if (!tokenRoomId) {
return next();
}
const sameRoom = payload.video?.room === roomId;
// If the user does not belong to the requested room, access is denied
if (!sameRoom) {
if (tokenRoomId !== roomId) {
const error = errorInsufficientPermissions();
return rejectRequestFromMeetError(res, error);
}
return next();
};
/**
* Middleware to configure authentication based on participant role and authentication mode to access a room
* for generating a token for retrieving/deleting recordings.
*
* - If the authentication mode is MODERATORS_ONLY and the participant role is MODERATOR, configure user authentication.
* - If the authentication mode is ALL_USERS, configure user authentication.
* - Otherwise, allow anonymous access.
*/
export const configureRecordingTokenAuth = async (req: Request, res: Response, next: NextFunction) => {
const configService = container.get(GlobalConfigService);
const roomService = container.get(RoomService);
let role: ParticipantRole;
try {
const roomId = req.params.roomId;
const { secret } = req.body;
const room = await roomService.getMeetRoom(roomId);
const recordingAccess = room.config.recording.allowAccessTo;
if (!recordingAccess || recordingAccess === MeetRecordingAccess.ADMIN) {
// Deny request if the room is configured to allow access to recordings only for admins
throw errorInsufficientPermissions();
}
role = await roomService.getRoomRoleBySecret(roomId, secret);
} catch (error) {
return handleError(res, error, 'getting room role by secret');
}
let authModeToAccessRoom: AuthMode;
try {
const { securityConfig } = await configService.getGlobalConfig();
authModeToAccessRoom = securityConfig.authentication.authModeToAccessRoom;
} catch (error) {
return handleError(res, error, 'checking authentication config');
}
const authValidators = [];
if (authModeToAccessRoom === AuthMode.NONE) {
authValidators.push(allowAnonymous);
} else {
const isModeratorsOnlyMode =
authModeToAccessRoom === AuthMode.MODERATORS_ONLY && role === ParticipantRole.MODERATOR;
const isAllUsersMode = authModeToAccessRoom === AuthMode.ALL_USERS;
if (isModeratorsOnlyMode || isAllUsersMode) {
authValidators.push(tokenAndRoleValidator(UserRole.USER));
} else {
authValidators.push(allowAnonymous);
}
}
return withAuth(...authValidators)(req, res, next);
};

View File

@ -167,10 +167,6 @@ export const errorRecordingsNotFromSameRoom = (roomId: string): OpenViduMeetErro
);
};
export const errorInvalidRecordingToken = (): OpenViduMeetError => {
return new OpenViduMeetError('Recording', 'Invalid recording token', 400);
};
const isMatchingError = (error: OpenViduMeetError, originalError: OpenViduMeetError): boolean => {
return (
error instanceof OpenViduMeetError &&
@ -217,6 +213,14 @@ export const errorDeletingRoom = (errorCode: MeetRoomDeletionErrorCode, message:
return new OpenViduMeetError(errorCode, message, 409);
};
export const errorInvalidRoomMemberToken = (): OpenViduMeetError => {
return new OpenViduMeetError('Room Error', 'Invalid room member token', 400);
};
export const errorInvalidRoomMemberRole = (): OpenViduMeetError => {
return new OpenViduMeetError('Room Error', 'No valid room member role provided', 400);
};
// Participant errors
export const errorParticipantNotFound = (participantIdentity: string, roomId: string): OpenViduMeetError => {
@ -227,22 +231,6 @@ export const errorParticipantNotFound = (participantIdentity: string, roomId: st
);
};
export const errorParticipantTokenNotPresent = (): OpenViduMeetError => {
return new OpenViduMeetError('Participant Error', 'No participant token provided', 400);
};
export const errorInvalidParticipantToken = (): OpenViduMeetError => {
return new OpenViduMeetError('Participant Error', 'Invalid participant token', 400);
};
export const errorInvalidParticipantRole = (): OpenViduMeetError => {
return new OpenViduMeetError('Participant Error', 'No valid participant role provided', 400);
};
export const errorParticipantIdentityNotProvided = (): OpenViduMeetError => {
return new OpenViduMeetError('Participant Error', 'No participant identity provided', 400);
};
// Webhook errors
export const errorInvalidWebhookUrl = (url: string, reason: string): OpenViduMeetError => {

View File

@ -1,4 +1,4 @@
import { AuthMode, AuthTransportMode, AuthType, GlobalConfig, MeetRoomThemeMode } from '@openvidu-meet/typings';
import { AuthMode, AuthType, GlobalConfig, MeetRoomThemeMode } from '@openvidu-meet/typings';
import { Document, model, Schema } from 'mongoose';
/**
@ -31,11 +31,6 @@ const AuthenticationConfigSchema = new Schema(
type: AuthMethodSchema,
required: true
},
authTransportMode: {
type: String,
enum: Object.values(AuthTransportMode),
required: true
},
authModeToAccessRoom: {
type: String,
enum: Object.values(AuthMode),

View File

@ -1,11 +1,11 @@
import { User, UserRole } from '@openvidu-meet/typings';
import { MeetUser, MeetUserRole } from '@openvidu-meet/typings';
import { Document, model, Schema } from 'mongoose';
/**
* Mongoose Document interface for User.
* Extends the User interface with MongoDB Document functionality.
*/
export interface MeetUserDocument extends User, Document {}
export interface MeetUserDocument extends MeetUser, Document {}
/**
* Mongoose schema for User entity.
@ -23,9 +23,9 @@ const MeetUserSchema = new Schema<MeetUserDocument>(
},
roles: {
type: [String],
enum: Object.values(UserRole),
enum: Object.values(MeetUserRole),
required: true,
default: [UserRole.USER]
default: [MeetUserRole.USER]
}
},
{

View File

@ -1,17 +1,17 @@
import { User } from '@openvidu-meet/typings';
import { MeetUser } from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { LoggerService } from '../services/logger.service.js';
import { BaseRepository } from './base.repository.js';
import { MeetUserDocument, MeetUserModel } from './schemas/user.schema.js';
/**
* Repository for managing User entities in MongoDB.
* Repository for managing MeetUser entities in MongoDB.
* Provides CRUD operations and specialized queries for user data.
*
* @template TUser - The domain type extending User (default: User)
* @template TUser - The domain type extending MeetUser (default: MeetUser)
*/
@injectable()
export class UserRepository<TUser extends User = User> extends BaseRepository<TUser, MeetUserDocument> {
export class UserRepository<TUser extends MeetUser = MeetUser> extends BaseRepository<TUser, MeetUserDocument> {
constructor(@inject(LoggerService) logger: LoggerService) {
super(logger, MeetUserModel);
}

View File

@ -1,4 +1,4 @@
import { UserRole } from '@openvidu-meet/typings';
import { MeetUserRole } from '@openvidu-meet/typings';
import bodyParser from 'body-parser';
import { Router } from 'express';
import * as analyticsCtrl from '../controllers/analytics.controller.js';
@ -9,4 +9,4 @@ analyticsRouter.use(bodyParser.urlencoded({ extended: true }));
analyticsRouter.use(bodyParser.json());
// Analytics Routes
analyticsRouter.get('/', withAuth(tokenAndRoleValidator(UserRole.ADMIN)), analyticsCtrl.getAnalytics);
analyticsRouter.get('/', withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN)), analyticsCtrl.getAnalytics);

View File

@ -1,4 +1,4 @@
import { UserRole } from '@openvidu-meet/typings';
import { MeetUserRole } from '@openvidu-meet/typings';
import bodyParser from 'body-parser';
import { Router } from 'express';
import * as apiKeyCtrl from '../controllers/api-key.controller.js';
@ -9,6 +9,6 @@ apiKeyRouter.use(bodyParser.urlencoded({ extended: true }));
apiKeyRouter.use(bodyParser.json());
// API Key Routes
apiKeyRouter.post('/', withAuth(tokenAndRoleValidator(UserRole.ADMIN)), apiKeyCtrl.createApiKey);
apiKeyRouter.get('/', withAuth(tokenAndRoleValidator(UserRole.ADMIN)), apiKeyCtrl.getApiKeys);
apiKeyRouter.delete('/', withAuth(tokenAndRoleValidator(UserRole.ADMIN)), apiKeyCtrl.deleteApiKeys);
apiKeyRouter.post('/', withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN)), apiKeyCtrl.createApiKey);
apiKeyRouter.get('/', withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN)), apiKeyCtrl.getApiKeys);
apiKeyRouter.delete('/', withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN)), apiKeyCtrl.deleteApiKeys);

View File

@ -1,4 +1,4 @@
import { UserRole } from '@openvidu-meet/typings';
import { MeetUserRole } from '@openvidu-meet/typings';
import bodyParser from 'body-parser';
import { Router } from 'express';
import * as appearanceConfigCtrl from '../controllers/global-config/appearance-config.controller.js';
@ -21,17 +21,17 @@ configRouter.use(bodyParser.json());
// Webhook config
configRouter.put(
'/webhooks',
withAuth(tokenAndRoleValidator(UserRole.ADMIN)),
withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN)),
validateWebhookConfig,
webhookConfigCtrl.updateWebhookConfig
);
configRouter.get('/webhooks', withAuth(tokenAndRoleValidator(UserRole.ADMIN)), webhookConfigCtrl.getWebhookConfig);
configRouter.get('/webhooks', withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN)), webhookConfigCtrl.getWebhookConfig);
configRouter.post('/webhooks/test', withValidWebhookTestRequest, webhookConfigCtrl.testWebhook);
// Security config
configRouter.put(
'/security',
withAuth(tokenAndRoleValidator(UserRole.ADMIN)),
withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN)),
validateSecurityConfig,
securityConfigCtrl.updateSecurityConfig
);
@ -40,7 +40,7 @@ configRouter.get('/security', withAuth(allowAnonymous), securityConfigCtrl.getSe
// Appearance config
configRouter.put(
'/rooms/appearance',
withAuth(tokenAndRoleValidator(UserRole.ADMIN)),
withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN)),
validateRoomsAppearanceConfig,
appearanceConfigCtrl.updateRoomsAppearanceConfig
);

View File

@ -4,7 +4,6 @@ export * from './api-key.routes.js';
export * from './user.routes.js';
export * from './room.routes.js';
export * from './meeting.routes.js';
export * from './participant.routes.js';
export * from './recording.routes.js';
export * from './livekit.routes.js';
export * from './analytics.routes.js';

View File

@ -1,9 +1,8 @@
import bodyParser from 'body-parser';
import { Router } from 'express';
import * as meetingCtrl from '../controllers/meeting.controller.js';
import * as participantCtrl from '../controllers/participant.controller.js';
import {
participantTokenValidator,
roomMemberTokenValidator,
validateUpdateParticipantRequest,
withAuth,
withModeratorPermissions,
@ -17,23 +16,23 @@ internalMeetingRouter.use(bodyParser.json());
// Internal Meetings Routes
internalMeetingRouter.delete(
'/:roomId',
withAuth(participantTokenValidator),
withAuth(roomMemberTokenValidator),
withValidRoomId,
withModeratorPermissions,
meetingCtrl.endMeeting
);
internalMeetingRouter.delete(
'/:roomId/participants/:participantIdentity',
withAuth(participantTokenValidator),
withAuth(roomMemberTokenValidator),
withValidRoomId,
withModeratorPermissions,
participantCtrl.kickParticipant
meetingCtrl.kickParticipantFromMeeting
);
internalMeetingRouter.put(
'/:roomId/participants/:participantIdentity/role',
withAuth(participantTokenValidator),
withAuth(roomMemberTokenValidator),
withValidRoomId,
withModeratorPermissions,
validateUpdateParticipantRequest,
participantCtrl.updateParticipantRole
meetingCtrl.updateParticipantRole
);

View File

@ -1,22 +0,0 @@
import bodyParser from 'body-parser';
import { Router } from 'express';
import * as participantCtrl from '../controllers/participant.controller.js';
import { configureParticipantTokenAuth, validateParticipantTokenRequest } from '../middlewares/index.js';
export const internalParticipantRouter: Router = Router();
internalParticipantRouter.use(bodyParser.urlencoded({ extended: true }));
internalParticipantRouter.use(bodyParser.json());
// Internal Participant Routes
internalParticipantRouter.post(
'/token',
validateParticipantTokenRequest,
configureParticipantTokenAuth,
participantCtrl.generateParticipantToken
);
internalParticipantRouter.post(
'/token/refresh',
validateParticipantTokenRequest,
configureParticipantTokenAuth,
participantCtrl.refreshParticipantToken
);

View File

@ -1,12 +1,11 @@
import { UserRole } from '@openvidu-meet/typings';
import { MeetUserRole } from '@openvidu-meet/typings';
import bodyParser from 'body-parser';
import { Router } from 'express';
import * as recordingCtrl from '../controllers/recording.controller.js';
import {
apiKeyValidator,
configureRecordingAuth,
participantTokenValidator,
recordingTokenValidator,
roomMemberTokenValidator,
tokenAndRoleValidator,
withAuth,
withCanDeleteRecordingsPermission,
@ -29,21 +28,21 @@ recordingRouter.use(bodyParser.json());
// Recording Routes
recordingRouter.get(
'/',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN), roomMemberTokenValidator),
withCanRetrieveRecordingsPermission,
withValidRecordingFiltersRequest,
recordingCtrl.getRecordings
);
recordingRouter.delete(
'/',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN), roomMemberTokenValidator),
withValidMultipleRecordingIds,
withCanDeleteRecordingsPermission,
recordingCtrl.bulkDeleteRecordings
);
recordingRouter.get(
'/download',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN), roomMemberTokenValidator),
withValidMultipleRecordingIds,
withCanRetrieveRecordingsPermission,
recordingCtrl.downloadRecordingsZip
@ -57,7 +56,7 @@ recordingRouter.get(
);
recordingRouter.delete(
'/:recordingId',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN), roomMemberTokenValidator),
withValidRecordingId,
withCanDeleteRecordingsPermission,
recordingCtrl.deleteRecording
@ -71,7 +70,7 @@ recordingRouter.get(
);
recordingRouter.get(
'/:recordingId/url',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN), roomMemberTokenValidator),
withValidGetRecordingUrlRequest,
withCanRetrieveRecordingsPermission,
recordingCtrl.getRecordingUrl
@ -86,7 +85,7 @@ internalRecordingRouter.post(
'/',
withValidStartRecordingRequest,
withRecordingEnabled,
withAuth(participantTokenValidator),
withAuth(roomMemberTokenValidator),
withCanRecordPermission,
recordingCtrl.startRecording
);
@ -94,7 +93,7 @@ internalRecordingRouter.post(
'/:recordingId/stop',
withValidRecordingId,
withRecordingEnabled,
withAuth(participantTokenValidator),
withAuth(roomMemberTokenValidator),
withCanRecordPermission,
recordingCtrl.stopRecording
);

View File

@ -1,13 +1,13 @@
import { UserRole } from '@openvidu-meet/typings';
import { MeetUserRole } from '@openvidu-meet/typings';
import bodyParser from 'body-parser';
import { Router } from 'express';
import * as roomCtrl from '../controllers/room.controller.js';
import {
allowAnonymous,
apiKeyValidator,
configureRecordingTokenAuth,
configureRoomAuthorization,
participantTokenValidator,
configureRoomMemberTokenAuth,
roomMemberTokenValidator,
tokenAndRoleValidator,
withAuth,
withValidRoomBulkDeleteRequest,
@ -15,8 +15,8 @@ import {
withValidRoomDeleteRequest,
withValidRoomFiltersRequest,
withValidRoomId,
withValidRoomMemberTokenRequest,
withValidRoomOptions,
withValidRoomSecret,
withValidRoomStatus
} from '../middlewares/index.js';
@ -27,47 +27,47 @@ roomRouter.use(bodyParser.json());
// Room Routes
roomRouter.post(
'/',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN)),
withValidRoomOptions,
roomCtrl.createRoom
);
roomRouter.get(
'/',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN)),
withValidRoomFiltersRequest,
roomCtrl.getRooms
);
roomRouter.delete(
'/',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN)),
withValidRoomBulkDeleteRequest,
roomCtrl.bulkDeleteRooms
);
roomRouter.get(
'/:roomId',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), participantTokenValidator),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN), roomMemberTokenValidator),
withValidRoomId,
configureRoomAuthorization,
roomCtrl.getRoom
);
roomRouter.delete(
'/:roomId',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN)),
withValidRoomDeleteRequest,
roomCtrl.deleteRoom
);
roomRouter.get(
'/:roomId/config',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), participantTokenValidator),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN), roomMemberTokenValidator),
withValidRoomId,
configureRoomAuthorization,
roomCtrl.getRoomConfig
);
roomRouter.put(
'/:roomId/config',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN)),
withValidRoomId,
withValidRoomConfig,
roomCtrl.updateRoomConfig
@ -75,7 +75,7 @@ roomRouter.put(
roomRouter.put(
'/:roomId/status',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
withAuth(apiKeyValidator, tokenAndRoleValidator(MeetUserRole.ADMIN)),
withValidRoomId,
withValidRoomStatus,
roomCtrl.updateRoomStatus
@ -87,21 +87,21 @@ internalRoomRouter.use(bodyParser.urlencoded({ extended: true }));
internalRoomRouter.use(bodyParser.json());
internalRoomRouter.post(
'/:roomId/recording-token',
configureRecordingTokenAuth,
'/:roomId/token',
withValidRoomId,
withValidRoomSecret,
roomCtrl.generateRecordingToken
withValidRoomMemberTokenRequest,
configureRoomMemberTokenAuth,
roomCtrl.generateRoomMemberToken
);
internalRoomRouter.get(
'/:roomId/roles',
withAuth(allowAnonymous),
withValidRoomId,
roomCtrl.getRoomRolesAndPermissions
roomCtrl.getRoomMemberRolesAndPermissions
);
internalRoomRouter.get(
'/:roomId/roles/:secret',
withAuth(allowAnonymous),
withValidRoomId,
roomCtrl.getRoomRoleAndPermissions
roomCtrl.getRoomMemberRoleAndPermissions
);

View File

@ -1,4 +1,4 @@
import { UserRole } from '@openvidu-meet/typings';
import { MeetUserRole } from '@openvidu-meet/typings';
import bodyParser from 'body-parser';
import { Router } from 'express';
import * as userCtrl from '../controllers/user.controller.js';
@ -9,10 +9,10 @@ userRouter.use(bodyParser.urlencoded({ extended: true }));
userRouter.use(bodyParser.json());
// Users Routes
userRouter.get('/profile', withAuth(tokenAndRoleValidator(UserRole.ADMIN, UserRole.USER)), userCtrl.getProfile);
userRouter.get('/profile', withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN, MeetUserRole.USER)), userCtrl.getProfile);
userRouter.post(
'/change-password',
withAuth(tokenAndRoleValidator(UserRole.ADMIN, UserRole.USER)),
withAuth(tokenAndRoleValidator(MeetUserRole.ADMIN, MeetUserRole.USER)),
validateChangePasswordRequest,
userCtrl.changePassword
);

View File

@ -5,14 +5,13 @@ import express, { Express, Request, Response } from 'express';
import { initializeEagerServices, registerDependencies } from './config/index.js';
import { INTERNAL_CONFIG } from './config/internal-config.js';
import { MEET_EDITION, SERVER_CORS_ORIGIN, SERVER_PORT, logEnvVars } from './environment.js';
import { httpContextMiddleware, jsonSyntaxErrorHandler } from './middlewares/index.js';
import { initRequestContext, jsonSyntaxErrorHandler, setBaseUrlMiddleware } from './middlewares/index.js';
import {
analyticsRouter,
apiKeyRouter,
authRouter,
configRouter,
internalMeetingRouter,
internalParticipantRouter,
internalRecordingRouter,
internalRoomRouter,
livekitWebhookRouter,
@ -49,8 +48,13 @@ const createApp = () => {
app.use(jsonSyntaxErrorHandler);
app.use(cookieParser());
// Middleware to set HTTP context
app.use(httpContextMiddleware);
// CRITICAL: Initialize request context FIRST
// This middleware creates an isolated AsyncLocalStorage context for each request
// Must be registered before any middleware that uses RequestSessionService
app.use(initRequestContext);
// Middleware to set base URL for each request
app.use(setBaseUrlMiddleware);
// Public API routes
app.use(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/docs`, (_req: Request, res: Response) =>
@ -72,7 +76,6 @@ const createApp = () => {
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/users`, userRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/rooms`, internalRoomRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/meetings`, internalMeetingRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/participants`, internalParticipantRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings`, internalRecordingRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/config`, configRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/analytics`, analyticsRouter);

View File

@ -3,7 +3,7 @@ import { injectable } from 'inversify';
import { SERVER_PORT } from '../environment.js';
@injectable()
export class HttpContextService {
export class BaseUrlService {
private baseUrl: string;
constructor() {
@ -11,9 +11,9 @@ export class HttpContextService {
}
/**
* Sets the current HTTP context from the request
* Sets the base URL from the request
*/
setContext(req: Request): void {
setBaseUrlFromRequest(req: Request): void {
const protocol = req.protocol;
const host = req.get('host');
this.baseUrl = `${protocol}://${host}`;
@ -27,9 +27,9 @@ export class HttpContextService {
}
/**
* Clears the current context
* Clears the current base URL by resetting to default
*/
clearContext(): void {
clearBaseUrl(): void {
this.baseUrl = this.getDefaultBaseUrl();
}

View File

@ -1,9 +1,9 @@
import {
MeetParticipantRoleUpdatedPayload,
MeetRecordingInfo,
MeetRoom,
ParticipantRole,
MeetParticipantRoleUpdatedPayload,
MeetRoomConfigUpdatedPayload,
MeetRoomMemberRole,
MeetSignalPayload,
MeetSignalType
} from '@openvidu-meet/typings';
@ -97,7 +97,7 @@ export class FrontendEventService {
async sendParticipantRoleUpdatedSignal(
roomId: string,
participantIdentity: string,
newRole: ParticipantRole,
newRole: MeetRoomMemberRole,
secret: string
): Promise<void> {
this.logger.debug(

View File

@ -1,4 +1,4 @@
import { AuthMode, AuthTransportMode, AuthType, GlobalConfig } from '@openvidu-meet/typings';
import { AuthMode, AuthType, GlobalConfig } from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import {
MEET_INITIAL_API_KEY,
@ -93,8 +93,7 @@ export class GlobalConfigService {
authMethod: {
type: AuthType.SINGLE_USER
},
authModeToAccessRoom: AuthMode.NONE,
authTransportMode: AuthTransportMode.HEADER
authModeToAccessRoom: AuthMode.NONE
}
},
roomsConfig: {

View File

@ -3,7 +3,8 @@ export * from './redis.service.js';
export * from './distributed-event.service.js';
export * from './mutex.service.js';
export * from './task-scheduler.service.js';
export * from './http-context.service.js';
export * from './base-url.service.js';
export * from './request-session.service.js';
export * from './token.service.js';
export * from './user.service.js';
@ -18,7 +19,7 @@ export * from './frontend-event.service.js';
export * from './recording.service.js';
export * from './room.service.js';
export * from './participant-name.service.js';
export * from './participant.service.js';
export * from './room-member.service.js';
export * from './openvidu-webhook.service.js';
export * from './livekit-webhook.service.js';
export * from './analytics.service.js';

View File

@ -13,8 +13,8 @@ import {
LoggerService,
MutexService,
OpenViduWebhookService,
ParticipantService,
RecordingService,
RoomMemberService,
RoomService
} from './index.js';
@ -31,7 +31,7 @@ export class LivekitWebhookService {
@inject(MutexService) protected mutexService: MutexService,
@inject(DistributedEventService) protected distributedEventService: DistributedEventService,
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
@inject(ParticipantService) protected participantService: ParticipantService,
@inject(RoomMemberService) protected roomMemberService: RoomMemberService,
@inject(LoggerService) protected logger: LoggerService
) {
this.webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
@ -189,7 +189,7 @@ export class LivekitWebhookService {
try {
// Release the participant's reserved name
await this.participantService.releaseParticipantName(room.name, participant.name);
await this.roomMemberService.releaseParticipantName(room.name, participant.name);
this.logger.verbose(`Released name for participant '${participant.name}' in room '${room.name}'`);
} catch (error) {
this.logger.error('Error releasing participant name on participant left:', error);
@ -280,7 +280,7 @@ export class LivekitWebhookService {
this.openViduWebhookService.sendMeetingEndedWebhook(meetRoom);
tasks.push(
this.participantService.cleanupParticipantNames(roomId),
this.roomMemberService.cleanupParticipantNames(roomId),
this.recordingService.releaseRecordingLockIfNoEgress(roomId)
);
await Promise.all(tasks);

View File

@ -182,22 +182,11 @@ export class LiveKitService {
}
}
async participantExists(
roomName: string,
participantNameOrIdentity: string,
participantField: 'name' | 'identity' = 'identity'
): Promise<boolean> {
async participantExists(roomName: string, participantIdentity: string): Promise<boolean> {
try {
const participants: ParticipantInfo[] = await this.listRoomParticipants(roomName);
return participants.some((participant) => {
let fieldValue = participant[participantField];
// If the field is empty or undefined, use identity as a fallback
if (!fieldValue && participantField === 'name') {
fieldValue = participant.identity;
}
return fieldValue === participantNameOrIdentity;
return participant.identity === participantIdentity;
});
} catch (error: any) {
this.logger.error(error);

View File

@ -1,4 +1,3 @@
import { AuthTransportMode, GlobalConfig } from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import ms from 'ms';
import { MeetLock } from '../helpers/index.js';
@ -135,11 +134,8 @@ export class MigrationService {
return;
}
// Add missing fields for backwards compatibility
const updatedConfig = this.addMissingFieldToGlobalConfig(legacyConfig);
// Save to MongoDB
await this.configRepository.create(updatedConfig);
await this.configRepository.create(legacyConfig);
this.logger.info('Global config migrated successfully');
// Delete from legacy storage
@ -392,21 +388,4 @@ export class MigrationService {
throw error;
}
}
/**
* Adds authTransportMode field to existing global config if missing.
*/
protected addMissingFieldToGlobalConfig(config: GlobalConfig): GlobalConfig {
// Check if authTransportMode is missing
const authConfig = config.securityConfig.authentication;
if (!('authTransportMode' in authConfig)) {
// Directly add the missing field to the existing object
Object.assign(config.securityConfig.authentication, {
authTransportMode: AuthTransportMode.HEADER
});
}
return config;
}
}

View File

@ -1,262 +0,0 @@
import {
MeetRoomStatus,
MeetTokenMetadata,
OpenViduMeetPermissions,
ParticipantOptions,
ParticipantPermissions,
ParticipantRole
} from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { ParticipantInfo } from 'livekit-server-sdk';
import { MeetRoomHelper } from '../helpers/room.helper.js';
import { validateMeetTokenMetadata } from '../middlewares/index.js';
import {
errorParticipantIdentityNotProvided,
errorParticipantNotFound,
errorRoomClosed
} from '../models/error.model.js';
import {
FrontendEventService,
LiveKitService,
LoggerService,
ParticipantNameService,
RoomService,
TokenService
} from './index.js';
@injectable()
export class ParticipantService {
constructor(
@inject(LoggerService) protected logger: LoggerService,
@inject(RoomService) protected roomService: RoomService,
@inject(LiveKitService) protected livekitService: LiveKitService,
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
@inject(TokenService) protected tokenService: TokenService,
@inject(ParticipantNameService) protected participantNameService: ParticipantNameService
) {}
async generateOrRefreshParticipantToken(
participantOptions: ParticipantOptions,
currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[],
refresh = false
): Promise<string> {
const { roomId, secret, participantName, participantIdentity } = participantOptions;
let finalParticipantName = participantName;
let finalParticipantOptions: ParticipantOptions = participantOptions;
if (participantName) {
// Check that room is open
const room = await this.roomService.getMeetRoom(roomId);
if (room.status === MeetRoomStatus.CLOSED) {
throw errorRoomClosed(roomId);
}
// Create the Livekit room if it doesn't exist
await this.roomService.createLivekitRoom(roomId);
if (refresh) {
if (!participantIdentity) {
throw errorParticipantIdentityNotProvided();
}
this.logger.verbose(`Refreshing participant token for '${participantIdentity}' in room '${roomId}'`);
// Check if participant with same participantIdentity exists in the room
const participantExists = await this.participantExists(roomId, participantIdentity, 'identity');
if (!participantExists) {
this.logger.verbose(`Participant '${participantIdentity}' does not exist in room '${roomId}'`);
throw errorParticipantNotFound(participantIdentity, roomId);
}
} else {
this.logger.verbose(`Generating participant token for '${participantName}' in room '${roomId}'`);
try {
// Reserve a unique name for the participant
finalParticipantName = await this.participantNameService.reserveUniqueName(roomId, participantName);
this.logger.verbose(`Reserved unique name '${finalParticipantName}' for room '${roomId}'`);
} catch (error) {
this.logger.error(
`Failed to reserve unique name '${participantName}' for room '${roomId}':`,
error
);
throw error;
}
// Update participantOptions with the final participant name
finalParticipantOptions = {
...participantOptions,
participantName: finalParticipantName
};
}
}
const role = await this.roomService.getRoomRoleBySecret(roomId, secret);
const token = await this.generateParticipantToken(finalParticipantOptions, role, currentRoles);
this.logger.verbose(
`Participant token generated for room '${roomId}'` +
(finalParticipantName ? ` with name '${finalParticipantName}'` : '')
);
return token;
}
protected async generateParticipantToken(
participantOptions: ParticipantOptions,
role: ParticipantRole,
currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[]
): Promise<string> {
const { roomId, participantName } = participantOptions;
const permissions = this.getParticipantPermissions(roomId, role, !!participantName);
if (!currentRoles.some((r) => r.role === role)) {
currentRoles.push({ role, permissions: permissions.openvidu });
}
return this.tokenService.generateParticipantToken(participantOptions, permissions.livekit, currentRoles, role);
}
async getParticipant(roomId: string, participantIdentity: string): Promise<ParticipantInfo> {
this.logger.verbose(`Fetching participant '${participantIdentity}'`);
return this.livekitService.getParticipant(roomId, participantIdentity);
}
async participantExists(
roomId: string,
participantNameOrIdentity: string,
participantField: 'name' | 'identity' = 'identity'
): Promise<boolean> {
this.logger.verbose(`Checking if participant '${participantNameOrIdentity}' exists in room '${roomId}'`);
return this.livekitService.participantExists(roomId, participantNameOrIdentity, participantField);
}
async kickParticipant(roomId: string, participantIdentity: string): Promise<void> {
this.logger.verbose(`Kicking participant '${participantIdentity}' from room '${roomId}'`);
return this.livekitService.deleteParticipant(roomId, participantIdentity);
}
getParticipantPermissions(roomId: string, role: ParticipantRole, addJoinPermission = true): ParticipantPermissions {
switch (role) {
case ParticipantRole.MODERATOR:
return this.generateModeratorPermissions(roomId, addJoinPermission);
case ParticipantRole.SPEAKER:
return this.generateSpeakerPermissions(roomId, addJoinPermission);
default:
throw new Error(`Role ${role} not supported`);
}
}
async updateParticipantRole(roomId: string, participantIdentity: string, newRole: ParticipantRole): Promise<void> {
try {
const meetRoom = await this.roomService.getMeetRoom(roomId);
const participant = await this.getParticipant(roomId, participantIdentity);
const metadata: MeetTokenMetadata = this.parseMetadata(participant.metadata);
// Update selected role and roles array
metadata.selectedRole = newRole;
const currentRoles = metadata.roles;
if (!currentRoles.some((r) => r.role === newRole)) {
const { openvidu } = this.getParticipantPermissions(roomId, newRole);
currentRoles.push({ role: newRole, permissions: openvidu });
}
await this.livekitService.updateParticipantMetadata(roomId, participantIdentity, JSON.stringify(metadata));
const { speakerSecret, moderatorSecret } = MeetRoomHelper.extractSecretsFromRoom(meetRoom);
const secret = newRole === ParticipantRole.MODERATOR ? moderatorSecret : speakerSecret;
await this.frontendEventService.sendParticipantRoleUpdatedSignal(
roomId,
participantIdentity,
newRole,
secret
);
} catch (error) {
this.logger.error('Error updating participant role:', error);
throw error;
}
}
parseMetadata(metadata: string): MeetTokenMetadata {
try {
const parsedMetadata = JSON.parse(metadata);
return validateMeetTokenMetadata(parsedMetadata);
} catch (error) {
this.logger.error('Failed to parse participant metadata:', error);
throw new Error('Invalid participant metadata format');
}
}
protected generateModeratorPermissions(roomId: string, addJoinPermission = true): ParticipantPermissions {
return {
livekit: {
roomJoin: addJoinPermission,
room: roomId,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: true
},
openvidu: {
canRecord: true,
canChat: true,
canChangeVirtualBackground: true
}
};
}
protected generateSpeakerPermissions(roomId: string, addJoinPermission = true): ParticipantPermissions {
return {
livekit: {
roomJoin: addJoinPermission,
room: roomId,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: true
},
openvidu: {
canRecord: false,
canChat: true,
canChangeVirtualBackground: true
}
};
}
/**
* Releases a participant's reserved name when they disconnect.
* This should be called when a participant leaves the room to free up the name.
*
* @param roomId - The room identifier
* @param participantName - The participant name to release
*/
async releaseParticipantName(roomId: string, participantName: string): Promise<void> {
try {
await this.participantNameService.releaseName(roomId, participantName);
this.logger.verbose(`Released participant name '${participantName}' for room '${roomId}'`);
} catch (error) {
this.logger.warn(`Error releasing participant name '${participantName}' for room '${roomId}':`, error);
}
}
/**
* Gets all currently reserved participant names in a room.
* Useful for debugging and monitoring.
*
* @param roomId - The room identifier
* @returns Promise<string[]> - Array of reserved participant names
*/
async getReservedNames(roomId: string): Promise<string[]> {
return await this.participantNameService.getReservedNames(roomId);
}
/**
* Cleans up expired participant name reservations for a room.
* This can be called during room cleanup or periodically.
*
* @param roomId - The room identifier
*/
async cleanupParticipantNames(roomId: string): Promise<void> {
await this.participantNameService.cleanupExpiredReservations(roomId);
}
}

View File

@ -567,12 +567,6 @@ export class RecordingService {
if (!room) throw errorRoomNotFound(roomId);
//TODO: Check if the room has participants before starting the recording
//room.numParticipants === 0 ? throw errorNoParticipants(roomId);
const lkRoom = await this.livekitService.getRoom(roomId);
if (!lkRoom) throw errorRoomNotFound(roomId);
const hasParticipants = await this.livekitService.roomHasParticipants(roomId);
if (!hasParticipants) throw errorRoomHasNoParticipants(roomId);

View File

@ -0,0 +1,137 @@
import { AsyncLocalStorage } from 'async_hooks';
import {
LiveKitPermissions,
MeetPermissions,
MeetRoomMemberRole,
MeetRoomMemberRoleAndPermissions,
MeetUser
} from '@openvidu-meet/typings';
import { injectable } from 'inversify';
/**
* Context stored per HTTP request using AsyncLocalStorage.
* This ensures that each concurrent request has its own isolated data.
*/
interface RequestContext {
user?: MeetUser;
roomMember?: MeetRoomMemberRoleAndPermissions;
}
/**
* Service that manages request-scoped session data using Node.js AsyncLocalStorage.
*
* This service provides isolated storage for each HTTP request without needing to pass
* the request object around or use Inversify's request scope. It works by leveraging
* Node.js's async_hooks module which tracks asynchronous execution contexts.
*
* IMPORTANT: This service is designed to work with HTTP requests, but it's also safe
* to use in other contexts (schedulers, webhooks, background jobs). When used outside
* an HTTP request context, all getters return undefined and setters are ignored.
*/
@injectable()
export class RequestSessionService {
private asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
private hasLoggedWarning = false;
/**
* Initializes the request context. Must be called at the start of each HTTP request.
* This method creates an isolated storage context for the duration of the request.
*
* @param callback - The function to execute within the request context
* @returns The result of the callback
*/
run<T>(callback: () => T): T {
return this.asyncLocalStorage.run({}, callback);
}
/**
* Gets the current request context.
* Returns undefined if called outside of a request context (e.g., in schedulers, background jobs).
* Logs a warning the first time this happens to help with debugging.
*/
private getContext(): RequestContext | undefined {
const context = this.asyncLocalStorage.getStore();
if (!context && !this.hasLoggedWarning) {
console.warn(
'RequestSessionService: No context found. ' +
'This service is being used outside of an HTTP request context (e.g., scheduler, webhook, background job). ' +
'All getters will return undefined and setters will be ignored. ' +
'This is expected behavior for non-HTTP contexts.'
);
this.hasLoggedWarning = true;
}
return context;
}
/**
* Sets the authenticated user in the current request context.
* If called outside a request context, this operation is silently ignored.
*/
setUser(user: MeetUser): void {
const context = this.getContext();
if (context) {
context.user = user;
}
}
/**
* Gets the authenticated user from the current request context.
*/
getUser(): MeetUser | undefined {
return this.getContext()?.user;
}
/**
* Sets the room member token information (role, permissions, and token claims)
* in the current request context.
* If called outside a request context, this operation is silently ignored.
*/
setRoomMemberTokenInfo(
role: MeetRoomMemberRole,
meetPermissions: MeetPermissions,
livekitPermissions: LiveKitPermissions
): void {
const context = this.getContext();
if (context) {
context.roomMember = {
role,
permissions: {
meet: meetPermissions,
livekit: livekitPermissions
}
};
}
}
/**
* Gets the room member role from the current request context.
*/
getRoomMemberRole(): MeetRoomMemberRole | undefined {
return this.getContext()?.roomMember?.role;
}
/**
* Gets the room member Meet permissions from the current request context.
*/
getRoomMemberMeetPermissions(): MeetPermissions | undefined {
return this.getContext()?.roomMember?.permissions.meet;
}
/**
* Gets the room member LiveKit permissions from the current request context.
*/
getRoomMemberLivekitPermissions(): LiveKitPermissions | undefined {
return this.getContext()?.roomMember?.permissions.livekit;
}
/**
* Gets the room ID from the token claims in the current request context.
*/
getRoomIdFromToken(): string | undefined {
return this.getContext()?.roomMember?.permissions.livekit.room;
}
}

View File

@ -0,0 +1,359 @@
import {
MeetRecordingAccess,
MeetRoomMemberPermissions,
MeetRoomMemberRole,
MeetRoomMemberTokenMetadata,
MeetRoomMemberTokenOptions,
MeetRoomStatus
} from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { ParticipantInfo } from 'livekit-server-sdk';
import { MeetRoomHelper } from '../helpers/room.helper.js';
import { validateRoomMemberTokenMetadata } from '../middlewares/index.js';
import { errorInvalidRoomSecret, errorParticipantNotFound, errorRoomClosed } from '../models/error.model.js';
import {
FrontendEventService,
LiveKitService,
LoggerService,
ParticipantNameService,
RoomService,
TokenService
} from './index.js';
/**
* Service for managing room members and meeting participants.
*/
@injectable()
export class RoomMemberService {
constructor(
@inject(LoggerService) protected logger: LoggerService,
@inject(RoomService) protected roomService: RoomService,
@inject(ParticipantNameService) protected participantNameService: ParticipantNameService,
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
@inject(LiveKitService) protected livekitService: LiveKitService,
@inject(TokenService) protected tokenService: TokenService
) {}
/**
* 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 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)
*/
async getRoomMemberRoleBySecret(roomId: string, secret: string): Promise<MeetRoomMemberRole> {
const room = await this.roomService.getMeetRoom(roomId);
const { moderatorSecret, speakerSecret } = MeetRoomHelper.extractSecretsFromRoom(room);
switch (secret) {
case moderatorSecret:
return MeetRoomMemberRole.MODERATOR;
case speakerSecret:
return MeetRoomMemberRole.SPEAKER;
default:
throw errorInvalidRoomSecret(room.roomId, secret);
}
}
/**
* Generates or refreshes a room member token.
*
* @param roomId - The room identifier
* @param tokenOptions - Options for token generation
* @returns A promise that resolves to the generated token
*/
async generateOrRefreshRoomMemberToken(roomId: string, tokenOptions: MeetRoomMemberTokenOptions): Promise<string> {
const { secret, grantJoinMeetingPermission = false, participantName, participantIdentity } = tokenOptions;
// Get room member role from secret
const role = await this.getRoomMemberRoleBySecret(roomId, secret);
if (grantJoinMeetingPermission && participantName) {
return this.generateTokenWithJoinMeetingPermission(roomId, role, participantName, participantIdentity);
} else {
return this.generateTokenWithoutJoinMeetingPermission(roomId, role);
}
}
/**
* Generates a token with join meeting permissions.
* Handles both new token generation and token refresh.
*/
protected async generateTokenWithJoinMeetingPermission(
roomId: string,
role: MeetRoomMemberRole,
participantName: string,
participantIdentity?: string
): Promise<string> {
// Check that room is open
const room = await this.roomService.getMeetRoom(roomId);
if (room.status === MeetRoomStatus.CLOSED) {
throw errorRoomClosed(roomId);
}
const isRefresh = !!participantIdentity;
if (!isRefresh) {
// GENERATION MODE
this.logger.verbose(
`Generating room member token with join meeting permission for '${participantName}' in room '${roomId}'`
);
// Create the Livekit room if it doesn't exist
await this.roomService.createLivekitRoom(roomId);
try {
// Reserve a unique name for the participant
participantName = await this.participantNameService.reserveUniqueName(roomId, participantName);
this.logger.verbose(`Reserved unique name '${participantName}' for room '${roomId}'`);
} catch (error) {
this.logger.error(`Failed to reserve unique name '${participantName}' for room '${roomId}':`, error);
throw error;
}
} else {
// REFRESH MODE
this.logger.verbose(
`Refreshing room member token for participant '${participantIdentity}' in room '${roomId}'`
);
// Check if participant exists in the room
const participantExists = await this.existsParticipantInMeeting(roomId, participantIdentity);
if (!participantExists) {
this.logger.verbose(`Participant '${participantIdentity}' does not exist in room '${roomId}'`);
throw errorParticipantNotFound(participantIdentity, roomId);
}
}
// Get participant permissions (with join meeting)
const permissions = await this.getRoomMemberPermissions(roomId, role, true);
// Generate token with participant name
return this.tokenService.generateRoomMemberToken(role, permissions, participantName);
}
/**
* Generates a token without join meeting permission.
* This token only provides access to other room resources (recordings, etc.)
*/
protected async generateTokenWithoutJoinMeetingPermission(
roomId: string,
role: MeetRoomMemberRole
): Promise<string> {
this.logger.verbose(`Generating room member token without join meeting permission for room '${roomId}'`);
// Get participant permissions (without join meeting)
const permissions = await this.getRoomMemberPermissions(roomId, role, false);
// Generate token without participant name
return this.tokenService.generateRoomMemberToken(role, permissions);
}
/**
* Gets the permissions for a room member based on their role.
*
* @param roomId - The ID of the room
* @param role - The role of the room member
* @param addJoinPermission - Whether to include join permission (for meeting access)
* @returns The permissions for the room member
*/
async getRoomMemberPermissions(
roomId: string,
role: MeetRoomMemberRole,
addJoinPermission = true
): Promise<MeetRoomMemberPermissions> {
const recordingPermissions = await this.getRecordingPermissions(roomId, role);
switch (role) {
case MeetRoomMemberRole.MODERATOR:
return this.generateModeratorPermissions(
roomId,
recordingPermissions.canRetrieveRecordings,
recordingPermissions.canDeleteRecordings,
addJoinPermission
);
case MeetRoomMemberRole.SPEAKER:
return this.generateSpeakerPermissions(
roomId,
recordingPermissions.canRetrieveRecordings,
recordingPermissions.canDeleteRecordings,
addJoinPermission
);
}
}
protected generateModeratorPermissions(
roomId: string,
canRetrieveRecordings: boolean,
canDeleteRecordings: boolean,
addJoinPermission: boolean
): MeetRoomMemberPermissions {
return {
livekit: {
roomJoin: addJoinPermission,
room: roomId,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: true
},
meet: {
canRecord: true,
canRetrieveRecordings,
canDeleteRecordings,
canChat: true,
canChangeVirtualBackground: true
}
};
}
protected generateSpeakerPermissions(
roomId: string,
canRetrieveRecordings: boolean,
canDeleteRecordings: boolean,
addJoinPermission: boolean
): MeetRoomMemberPermissions {
return {
livekit: {
roomJoin: addJoinPermission,
room: roomId,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: true
},
meet: {
canRecord: false,
canRetrieveRecordings,
canDeleteRecordings,
canChat: true,
canChangeVirtualBackground: true
}
};
}
protected async getRecordingPermissions(
roomId: string,
role: MeetRoomMemberRole
): Promise<{
canRetrieveRecordings: boolean;
canDeleteRecordings: boolean;
}> {
const room = await this.roomService.getMeetRoom(roomId);
const recordingAccess = room.config.recording.allowAccessTo;
if (!recordingAccess) {
// Default to no access if not configured
return {
canRetrieveRecordings: false,
canDeleteRecordings: false
};
}
// A room member can delete recordings if they are a moderator and the recording access is not set to admin
const canDeleteRecordings =
role === MeetRoomMemberRole.MODERATOR && recordingAccess !== MeetRecordingAccess.ADMIN;
/* A room member can retrieve recordings if
- they can delete recordings
- they are a speaker and the recording access includes speakers
*/
const canRetrieveRecordings =
canDeleteRecordings ||
(role === MeetRoomMemberRole.SPEAKER && recordingAccess === MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER);
return {
canRetrieveRecordings,
canDeleteRecordings
};
}
/**
* Parses and validates room member token metadata.
*/
parseRoomMemberTokenMetadata(metadata: string): MeetRoomMemberTokenMetadata {
try {
const parsedMetadata = JSON.parse(metadata);
return validateRoomMemberTokenMetadata(parsedMetadata);
} catch (error) {
this.logger.error('Failed to parse room member token metadata:', error);
throw new Error('Invalid room member token metadata format');
}
}
async kickParticipantFromMeeting(roomId: string, participantIdentity: string): Promise<void> {
this.logger.verbose(`Kicking participant '${participantIdentity}' from room '${roomId}'`);
return this.livekitService.deleteParticipant(roomId, participantIdentity);
}
async updateParticipantRole(
roomId: string,
participantIdentity: string,
newRole: MeetRoomMemberRole
): Promise<void> {
try {
const meetRoom = await this.roomService.getMeetRoom(roomId);
const participant = await this.getParticipantFromMeeting(roomId, participantIdentity);
const metadata: MeetRoomMemberTokenMetadata = this.parseRoomMemberTokenMetadata(participant.metadata);
// Update role and permissions in metadata
metadata.role = newRole;
const { meet } = await this.getRoomMemberPermissions(roomId, newRole);
metadata.permissions = meet;
await this.livekitService.updateParticipantMetadata(roomId, participantIdentity, JSON.stringify(metadata));
const { speakerSecret, moderatorSecret } = MeetRoomHelper.extractSecretsFromRoom(meetRoom);
const secret = newRole === MeetRoomMemberRole.MODERATOR ? moderatorSecret : speakerSecret;
await this.frontendEventService.sendParticipantRoleUpdatedSignal(
roomId,
participantIdentity,
newRole,
secret
);
} catch (error) {
this.logger.error('Error updating participant role:', error);
throw error;
}
}
protected async existsParticipantInMeeting(roomId: string, participantIdentity: string): Promise<boolean> {
this.logger.verbose(`Checking if participant '${participantIdentity}' exists in room '${roomId}'`);
return this.livekitService.participantExists(roomId, participantIdentity);
}
protected async getParticipantFromMeeting(roomId: string, participantIdentity: string): Promise<ParticipantInfo> {
this.logger.verbose(`Fetching participant '${participantIdentity}'`);
return this.livekitService.getParticipant(roomId, participantIdentity);
}
/**
* Releases a participant's reserved name when they disconnect from meeting.
* This should be called when a participant leaves the meeting to free up the name.
*
* @param roomId - The room identifier
* @param participantName - The participant name to release
*/
async releaseParticipantName(roomId: string, participantName: string): Promise<void> {
try {
await this.participantNameService.releaseName(roomId, participantName);
this.logger.verbose(`Released participant name '${participantName}' for room '${roomId}'`);
} catch (error) {
this.logger.warn(`Error releasing participant name '${participantName}' for room '${roomId}':`, error);
}
}
/**
* Cleans up expired participant name reservations for a meeting.
* This can be called during room cleanup or periodically.
*
* @param roomId - The room identifier
*/
async cleanupParticipantNames(roomId: string): Promise<void> {
await this.participantNameService.cleanupExpiredReservations(roomId);
}
}

View File

@ -1,6 +1,5 @@
import {
MeetingEndAction,
MeetRecordingAccess,
MeetRoom,
MeetRoomConfig,
MeetRoomDeletionErrorCode,
@ -8,10 +7,9 @@ import {
MeetRoomDeletionPolicyWithRecordings,
MeetRoomDeletionSuccessCode,
MeetRoomFilters,
MeetRoomMemberRole,
MeetRoomOptions,
MeetRoomStatus,
ParticipantRole,
RecordingPermissions
MeetRoomStatus
} from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { CreateOptions, Room } from 'livekit-server-sdk';
@ -21,10 +19,8 @@ import { uid } from 'uid/single';
import { INTERNAL_CONFIG } from '../config/internal-config.js';
import { MEET_NAME_ID } from '../environment.js';
import { MeetRoomHelper, UtilsHelper } from '../helpers/index.js';
import { validateRecordingTokenMetadata } from '../middlewares/index.js';
import {
errorDeletingRoom,
errorInvalidRoomSecret,
errorRoomActiveMeeting,
errorRoomNotFound,
internalError,
@ -32,14 +28,13 @@ import {
} from '../models/error.model.js';
import { RoomRepository } from '../repositories/index.js';
import {
DistributedEventService,
FrontendEventService,
IScheduledTask,
LiveKitService,
LoggerService,
RecordingService,
TaskSchedulerService,
TokenService
RequestSessionService,
TaskSchedulerService
} from './index.js';
/**
@ -55,10 +50,9 @@ export class RoomService {
@inject(RoomRepository) protected roomRepository: RoomRepository,
@inject(RecordingService) protected recordingService: RecordingService,
@inject(LiveKitService) protected livekitService: LiveKitService,
@inject(DistributedEventService) protected distributedEventService: DistributedEventService,
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
@inject(TaskSchedulerService) protected taskSchedulerService: TaskSchedulerService,
@inject(TokenService) protected tokenService: TokenService
@inject(RequestSessionService) protected requestSessionService: RequestSessionService
) {
const roomGarbageCollectorTask: IScheduledTask = {
name: 'roomGarbageCollector',
@ -223,7 +217,7 @@ export class RoomService {
* @param roomId - The name of the room to retrieve.
* @returns A promise that resolves to an {@link MeetRoom} object.
*/
async getMeetRoom(roomId: string, fields?: string, participantRole?: ParticipantRole): Promise<MeetRoom> {
async getMeetRoom(roomId: string, fields?: string): Promise<MeetRoom> {
const meetRoom = await this.roomRepository.findByRoomId(roomId);
if (!meetRoom) {
@ -233,8 +227,10 @@ export class RoomService {
const filteredRoom = UtilsHelper.filterObjectFields(meetRoom, fields);
// Remove moderatorUrl if the participant is a speaker to prevent access to moderator links
if (participantRole === ParticipantRole.SPEAKER) {
// Remove moderatorUrl if the room member is a speaker to prevent access to moderator links
const role = this.requestSessionService.getRoomMemberRole();
if (role === MeetRoomMemberRole.SPEAKER) {
delete filteredRoom.moderatorUrl;
}
@ -634,74 +630,6 @@ export class RoomService {
return { successful, failed };
}
/**
* 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 participant role (MODERATOR or SPEAKER) if the secret is valid
* @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)
*/
async getRoomRoleBySecret(roomId: string, secret: string): Promise<ParticipantRole> {
const room = await this.getMeetRoom(roomId);
const { moderatorSecret, speakerSecret } = MeetRoomHelper.extractSecretsFromRoom(room);
switch (secret) {
case moderatorSecret:
return ParticipantRole.MODERATOR;
case speakerSecret:
return ParticipantRole.SPEAKER;
default:
throw errorInvalidRoomSecret(room.roomId, secret);
}
}
/**
* Generates a token with recording permissions for a specific room.
*
* @param roomId - The unique identifier of the room for which the recording token is being generated.
* @param secret - The secret associated with the room, used to determine the user's role.
* @returns A promise that resolves to the generated recording token as a string.
* @throws An error if the room with the given `roomId` is not found.
*/
async generateRecordingToken(roomId: string, secret: string): Promise<string> {
const role = await this.getRoomRoleBySecret(roomId, secret);
const permissions = await this.getRecordingPermissions(roomId, role);
return await this.tokenService.generateRecordingToken(roomId, role, permissions);
}
protected async getRecordingPermissions(roomId: string, role: ParticipantRole): Promise<RecordingPermissions> {
const room = await this.getMeetRoom(roomId);
const recordingAccess = room.config?.recording.allowAccessTo;
// A participant can delete recordings if they are a moderator and the recording access is not set to admin
const canDeleteRecordings = role === ParticipantRole.MODERATOR && recordingAccess !== MeetRecordingAccess.ADMIN;
/* A participant can retrieve recordings if
- they can delete recordings
- they are a speaker and the recording access includes speakers
*/
const canRetrieveRecordings =
canDeleteRecordings ||
(role === ParticipantRole.SPEAKER && recordingAccess === MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER);
return {
canRetrieveRecordings,
canDeleteRecordings
};
}
parseRecordingTokenMetadata(metadata: string) {
try {
const parsedMetadata = JSON.parse(metadata);
return validateRecordingTokenMetadata(parsedMetadata);
} catch (error) {
this.logger.error('Failed to parse recording token metadata:', error);
throw new Error('Invalid recording token metadata format');
}
}
/**
* This method checks for rooms that have an auto-deletion date in the past and
* tries to delete them based on their auto-deletion policy.

View File

@ -1,4 +1,4 @@
import { GlobalConfig, MeetApiKey, MeetRecordingInfo, MeetRoom, User } from '@openvidu-meet/typings';
import { GlobalConfig, MeetApiKey, MeetRecordingInfo, MeetRoom, MeetUser } from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { OpenViduMeetError, RedisKeyName } from '../../models/index.js';
import { LoggerService, RedisService } from '../index.js';
@ -289,11 +289,11 @@ export class LegacyStorageService {
* @param username - The username of the user to retrieve
* @returns A promise that resolves to the user data, or null if not found
*/
async getUser(username: string): Promise<User | null> {
async getUser(username: string): Promise<MeetUser | null> {
const redisKey = RedisKeyName.USER + username;
const storageKey = this.keyBuilder.buildUserKey(username);
const user = await this.getFromCacheAndStorage<User>(redisKey, storageKey);
const user = await this.getFromCacheAndStorage<MeetUser>(redisKey, storageKey);
return user;
}

View File

@ -1,11 +1,8 @@
import {
LiveKitPermissions,
MeetTokenMetadata,
OpenViduMeetPermissions,
ParticipantOptions,
ParticipantRole,
RecordingPermissions,
User
MeetRoomMemberPermissions,
MeetRoomMemberRole,
MeetRoomMemberTokenMetadata,
MeetUser
} from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { jwtDecode } from 'jwt-decode';
@ -18,7 +15,7 @@ import { LoggerService } from './index.js';
export class TokenService {
constructor(@inject(LoggerService) protected logger: LoggerService) {}
async generateAccessToken(user: User): Promise<string> {
async generateAccessToken(user: MeetUser): Promise<string> {
const tokenOptions: AccessTokenOptions = {
identity: user.username,
ttl: INTERNAL_CONFIG.ACCESS_TOKEN_EXPIRATION,
@ -29,7 +26,7 @@ export class TokenService {
return await this.generateJwtToken(tokenOptions);
}
async generateRefreshToken(user: User): Promise<string> {
async generateRefreshToken(user: MeetUser): Promise<string> {
const tokenOptions: AccessTokenOptions = {
identity: user.username,
ttl: INTERNAL_CONFIG.REFRESH_TOKEN_EXPIRATION,
@ -40,54 +37,24 @@ export class TokenService {
return await this.generateJwtToken(tokenOptions);
}
async generateParticipantToken(
participantOptions: ParticipantOptions,
lkPermissions: LiveKitPermissions,
roles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[],
selectedRole: ParticipantRole
async generateRoomMemberToken(
role: MeetRoomMemberRole,
permissions: MeetRoomMemberPermissions,
participantName?: string
): Promise<string> {
const { roomId, participantName } = participantOptions;
this.logger.info(
`Generating token for room '${roomId}'` + (participantName ? ` and participant '${participantName}'` : '')
);
let { participantIdentity } = participantOptions;
if (participantName && !participantIdentity) {
participantIdentity = participantName;
}
const metadata: MeetTokenMetadata = {
const metadata: MeetRoomMemberTokenMetadata = {
livekitUrl: LIVEKIT_URL,
roles,
selectedRole
role,
permissions: permissions.meet
};
const tokenOptions: AccessTokenOptions = {
identity: participantIdentity,
identity: participantName,
name: participantName,
ttl: INTERNAL_CONFIG.PARTICIPANT_TOKEN_EXPIRATION,
ttl: INTERNAL_CONFIG.ROOM_MEMBER_TOKEN_EXPIRATION,
metadata: JSON.stringify(metadata)
};
return await this.generateJwtToken(tokenOptions, lkPermissions as VideoGrant);
}
async generateRecordingToken(
roomId: string,
role: ParticipantRole,
permissions: RecordingPermissions
): Promise<string> {
this.logger.info(`Generating recording token for room ${roomId}`);
const tokenOptions: AccessTokenOptions = {
ttl: INTERNAL_CONFIG.RECORDING_TOKEN_EXPIRATION,
metadata: JSON.stringify({
role,
recordingPermissions: permissions
})
};
const grants: VideoGrant = {
room: roomId
};
return await this.generateJwtToken(tokenOptions, grants);
return await this.generateJwtToken(tokenOptions, permissions.livekit as VideoGrant);
}
private async generateJwtToken(tokenOptions: AccessTokenOptions, grants?: VideoGrant): Promise<string> {

View File

@ -1,4 +1,4 @@
import { User, UserDTO, UserRole } from '@openvidu-meet/typings';
import { MeetUser, MeetUserDTO, MeetUserRole } from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { INTERNAL_CONFIG } from '../config/internal-config.js';
import { MEET_INITIAL_ADMIN_PASSWORD, MEET_INITIAL_ADMIN_USER } from '../environment.js';
@ -18,17 +18,17 @@ export class UserService {
* Initializes the default admin user
*/
async initializeAdminUser(): Promise<void> {
const admin: User = {
const admin: MeetUser = {
username: MEET_INITIAL_ADMIN_USER,
passwordHash: await PasswordHelper.hashPassword(MEET_INITIAL_ADMIN_PASSWORD),
roles: [UserRole.ADMIN, UserRole.USER]
roles: [MeetUserRole.ADMIN, MeetUserRole.USER]
};
await this.userRepository.create(admin);
this.logger.info(`Admin user initialized with default credentials`);
}
async authenticateUser(username: string, password: string): Promise<User | null> {
async authenticateUser(username: string, password: string): Promise<MeetUser | null> {
const user = await this.getUser(username);
if (!user || !(await PasswordHelper.verifyPassword(password, user.passwordHash))) {
@ -38,23 +38,23 @@ export class UserService {
return user;
}
async getUser(username: string): Promise<User | null> {
async getUser(username: string): Promise<MeetUser | null> {
return this.userRepository.findByUsername(username);
}
getAnonymousUser(): User {
getAnonymousUser(): MeetUser {
return {
username: INTERNAL_CONFIG.ANONYMOUS_USER,
passwordHash: '',
roles: [UserRole.USER]
roles: [MeetUserRole.USER]
};
}
getApiUser(): User {
getApiUser(): MeetUser {
return {
username: INTERNAL_CONFIG.API_USER,
passwordHash: '',
roles: [UserRole.APP]
roles: [MeetUserRole.APP]
};
}
@ -76,7 +76,7 @@ export class UserService {
}
// Convert user to UserDTO to remove sensitive information
convertToDTO(user: User): UserDTO {
convertToDTO(user: MeetUser): MeetUserDTO {
const { passwordHash, ...userDTO } = user;
return userDTO;
}

View File

@ -1,13 +0,0 @@
import { CookieOptions } from 'express';
import ms, { StringValue } from 'ms';
export const getCookieOptions = (path: string, expiration?: string): CookieOptions => {
return {
httpOnly: true,
secure: true,
sameSite: 'none',
partitioned: true,
maxAge: expiration ? ms(expiration as StringValue) : undefined,
path
};
};

View File

@ -1,5 +1,4 @@
export * from './array.utils.js';
export * from './cookie.utils.js';
export * from './token.utils.js';
export * from './url.utils.js';
export * from './path.utils.js';

View File

@ -1,26 +1,5 @@
import { AuthTransportMode } from '@openvidu-meet/typings';
import { Request } from 'express';
import { container } from '../config/index.js';
import { INTERNAL_CONFIG } from '../config/internal-config.js';
import { GlobalConfigService, LoggerService } from '../services/index.js';
/**
* Gets the current authentication transport mode from global config.
*
* @returns The current transport mode
*/
export const getAuthTransportMode = async (): Promise<AuthTransportMode> => {
try {
const configService = container.get(GlobalConfigService);
const globalConfig = await configService.getGlobalConfig();
return globalConfig.securityConfig.authentication.authTransportMode;
} catch (error) {
const logger = container.get(LoggerService);
logger.error('Error fetching auth transport mode:', error);
// Fallback to header mode in case of error
return AuthTransportMode.HEADER;
}
};
/**
* Extracts the access token from the request based on the configured transport mode.
@ -28,13 +7,8 @@ export const getAuthTransportMode = async (): Promise<AuthTransportMode> => {
* @param req - Express request object
* @returns The JWT token string or undefined if not found
*/
export const getAccessToken = async (req: Request): Promise<string | undefined> => {
return getTokenFromRequest(
req,
INTERNAL_CONFIG.ACCESS_TOKEN_HEADER,
INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME,
'accessToken'
);
export const getAccessToken = (req: Request): string | undefined => {
return getTokenFromRequest(req, INTERNAL_CONFIG.ACCESS_TOKEN_HEADER, 'accessToken');
};
/**
@ -43,37 +17,18 @@ export const getAccessToken = async (req: Request): Promise<string | undefined>
* @param req - Express request object
* @returns The JWT refresh token string or undefined if not found
*/
export const getRefreshToken = async (req: Request): Promise<string | undefined> => {
return getTokenFromRequest(req, INTERNAL_CONFIG.REFRESH_TOKEN_HEADER, INTERNAL_CONFIG.REFRESH_TOKEN_COOKIE_NAME);
export const getRefreshToken = (req: Request): string | undefined => {
return getTokenFromRequest(req, INTERNAL_CONFIG.REFRESH_TOKEN_HEADER);
};
/**
* Extracts the participant token from the request based on the configured transport mode.
* Extracts the room member token from the request based on the configured transport mode.
*
* @param req - Express request object
* @returns The JWT participant token string or undefined if not found
* @returns The JWT room member token string or undefined if not found
*/
export const getParticipantToken = async (req: Request): Promise<string | undefined> => {
return getTokenFromRequest(
req,
INTERNAL_CONFIG.PARTICIPANT_TOKEN_HEADER,
INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME
);
};
/**
* Extracts the recording token from the request based on the configured transport mode.
*
* @param req - Express request object
* @returns The JWT recording token string or undefined if not found
*/
export const getRecordingToken = async (req: Request): Promise<string | undefined> => {
return getTokenFromRequest(
req,
INTERNAL_CONFIG.RECORDING_TOKEN_HEADER,
INTERNAL_CONFIG.RECORDING_TOKEN_COOKIE_NAME,
'recordingToken'
);
export const getRoomMemberToken = (req: Request): string | undefined => {
return getTokenFromRequest(req, INTERNAL_CONFIG.ROOM_MEMBER_TOKEN_HEADER, 'roomMemberToken');
};
/**
@ -81,23 +36,10 @@ export const getRecordingToken = async (req: Request): Promise<string | undefine
*
* @param req - Express request object
* @param headerName - Name of the header to check
* @param cookieName - Name of the cookie to check
* @param queryParamName - (Optional) Name of the query parameter to check (for access and recording tokens)
* @param queryParamName - (Optional) Name of the query parameter to check (for access and room member tokens)
* @returns The JWT token string or undefined if not found
*/
const getTokenFromRequest = async (
req: Request,
headerName: string,
cookieName: string,
queryParamName?: string
): Promise<string | undefined> => {
const transportMode = await getAuthTransportMode();
if (transportMode === AuthTransportMode.COOKIE) {
// Try to get from cookie
return req.cookies[cookieName];
}
const getTokenFromRequest = (req: Request, headerName: string, queryParamName?: string): string | undefined => {
// Try to get from header
const headerValue = req.headers[headerName];
@ -108,7 +50,7 @@ const getTokenFromRequest = async (
/**
* If not found in header, try to get from query parameter
* This is needed to send access/recording tokens via URL for video playback
* This is needed to send tokens via URL for video playback
* since we cannot set custom headers in video element requests
*/
if (queryParamName) {

View File

@ -1,6 +1,6 @@
import { container } from '../config/dependency-injector.config.js';
import { MEET_BASE_URL } from '../environment.js';
import { HttpContextService } from '../services/http-context.service.js';
import { BaseUrlService } from '../services/base-url.service.js';
/**
* Returns the base URL for the application.
@ -28,6 +28,6 @@ export const getBaseUrl = (): string => {
return baseUrl;
}
const httpContextService = container.get(HttpContextService);
return httpContextService.getBaseUrl();
const baseUrlService = container.get(BaseUrlService);
return baseUrlService.getBaseUrl();
};

Some files were not shown because too many files have changed in this diff Show More