backend: refactor auth middlewares and data stored in request context

This commit is contained in:
juancarmore 2025-12-05 15:28:12 +01:00
parent 53ac60d37a
commit 5d874f57f5
7 changed files with 78 additions and 95 deletions

View File

@ -17,10 +17,6 @@ export const INTERNAL_CONFIG = {
REFRESH_TOKEN_EXPIRATION: '1d',
ROOM_MEMBER_TOKEN_EXPIRATION: '2h',
// Authentication usernames
ANONYMOUS_USER: 'anonymous',
API_USER: 'api-user',
// S3 configuration
S3_MAX_RETRIES_ATTEMPTS_ON_SAVE_ERROR: '5',
S3_INITIAL_RETRY_DELAY_MS: '100',

View File

@ -1,4 +1,4 @@
import { LiveKitPermissions, MeetUser, MeetUserRole } from '@openvidu-meet/typings';
import { 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';
@ -9,6 +9,7 @@ import {
OpenViduMeetError,
errorInsufficientPermissions,
errorInvalidApiKey,
errorInvalidApiKeySubject,
errorInvalidRoomMemberToken,
errorInvalidToken,
errorInvalidTokenSubject,
@ -27,7 +28,7 @@ import { getAccessToken, getRoomMemberToken } from '../utils/token.utils.js';
* Interface for authentication validators.
* Each validator must implement methods to check if credentials are present and to validate them.
*/
interface AuthValidator {
export interface AuthValidator {
/**
* Checks if the authentication credentials for this validator are present in the request.
* This allows the middleware to skip validation for methods that are not being used.
@ -74,7 +75,7 @@ export const withAuth = (...validators: AuthValidator[]): RequestHandler => {
/**
* Token and role validator for role-based access.
* Validates JWT tokens and checks if the user has at least one of the required roles.
* Validates JWT tokens and checks if the user has one of the required roles.
*
* @param roles One or more roles that are allowed to access the resource
*/
@ -92,27 +93,25 @@ export const tokenAndRoleValidator = (...roles: MeetUserRole[]): AuthValidator =
throw errorUnauthorized();
}
const tokenService = container.get(TokenService);
let payload: ClaimGrants;
try {
const tokenService = container.get(TokenService);
payload = await tokenService.verifyToken(token);
} catch (error) {
throw errorInvalidToken();
}
const username = payload.sub;
const userService = container.get(UserService);
const user = username ? await userService.getUser(username) : null;
const userId = payload.sub;
const user = userId ? await userService.getUser(userId) : null;
if (!user) {
throw errorInvalidTokenSubject();
}
// Check if user has at least one of the required roles
const hasRequiredRole = roles.some((role) => user.roles.includes(role));
if (!hasRequiredRole) {
// Check if user has one of the required roles
if (!roles.includes(user.role)) {
throw errorInsufficientPermissions();
}
@ -124,7 +123,7 @@ export const tokenAndRoleValidator = (...roles: MeetUserRole[]): AuthValidator =
/**
* Room member token validator for room access.
* Validates room member tokens and checks role permissions.
* Validates room member tokens and sets the room member metadata in the session.
*/
export const roomMemberTokenValidator: AuthValidator = {
async isPresent(req: Request): Promise<boolean> {
@ -140,13 +139,12 @@ export const roomMemberTokenValidator: AuthValidator = {
}
let tokenMetadata: string | undefined;
let livekitPermissions: LiveKitPermissions | undefined;
try {
const tokenService = container.get(TokenService);
({ metadata: tokenMetadata, video: livekitPermissions } = await tokenService.verifyToken(token));
({ metadata: tokenMetadata } = await tokenService.verifyToken(token));
if (!tokenMetadata || !livekitPermissions) {
if (!tokenMetadata) {
throw new Error('Missing required token claims');
}
} catch (error) {
@ -155,22 +153,23 @@ export const roomMemberTokenValidator: AuthValidator = {
const requestSessionService = container.get(RequestSessionService);
// Validate the room member token metadata and extract role and permissions
// Validate the room member token metadata and set it in the session
try {
const roomMemberService = container.get(RoomMemberService);
const { role, permissions: meetPermissions } =
roomMemberService.parseRoomMemberTokenMetadata(tokenMetadata);
requestSessionService.setRoomMemberTokenInfo(role, meetPermissions, livekitPermissions);
const parsedMetadata = roomMemberService.parseRoomMemberTokenMetadata(tokenMetadata);
requestSessionService.setRoomMemberTokenMetadata(parsedMetadata);
} catch (error) {
const logger = container.get(LoggerService);
logger.error('Invalid room member token:', error);
throw errorInvalidRoomMemberToken();
}
// Set authenticated user if present, otherwise anonymous
// Set authenticated user if present
const user = await getAuthenticatedUserOrAnonymous(req);
requestSessionService.setUser(user);
if (user) {
requestSessionService.setUser(user);
}
}
};
@ -199,7 +198,11 @@ export const apiKeyValidator: AuthValidator = {
}
const userService = container.get(UserService);
const apiUser = userService.getApiUser();
const apiUser = await userService.getUserAssociatedWithApiKey();
if (!apiUser) {
throw errorInvalidApiKeySubject();
}
const requestSessionService = container.get(RequestSessionService);
requestSessionService.setUser(apiUser);
@ -208,7 +211,7 @@ export const apiKeyValidator: AuthValidator = {
/**
* Anonymous access validator.
* Always present and allows unauthenticated access with an anonymous user.
* Allows unauthenticated access with an anonymous user.
*/
export const allowAnonymous: AuthValidator = {
async isPresent(): Promise<boolean> {
@ -219,14 +222,15 @@ export const allowAnonymous: AuthValidator = {
async validate(req: Request): Promise<void> {
const user = await getAuthenticatedUserOrAnonymous(req);
const requestSessionService = container.get(RequestSessionService);
requestSessionService.setUser(user);
if (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<MeetUser> => {
const userService = container.get(UserService);
// Return the authenticated user if available, otherwise return null
const getAuthenticatedUserOrAnonymous = async (req: Request): Promise<MeetUser | null> => {
let user: MeetUser | null = null;
// Check if there is a user already authenticated
@ -236,18 +240,16 @@ const getAuthenticatedUserOrAnonymous = async (req: Request): Promise<MeetUser>
try {
const tokenService = container.get(TokenService);
const payload = await tokenService.verifyToken(token);
const username = payload.sub;
user = username ? await userService.getUser(username) : null;
const userId = payload.sub;
const userService = container.get(UserService);
user = userId ? await userService.getUser(userId) : null;
} catch (error) {
const logger = container.get(LoggerService);
logger.debug('Token found but invalid:' + error);
}
}
if (!user) {
user = userService.getAnonymousUser();
}
return user;
};

View File

@ -101,6 +101,14 @@ export const errorInvalidApiKey = (): OpenViduMeetError => {
return new OpenViduMeetError('Authentication Error', 'Invalid API key', 401);
};
export const errorInvalidApiKeySubject = (): OpenViduMeetError => {
return new OpenViduMeetError(
'Authorization Error',
'Invalid API key subject. The user associated with the API key does not exist',
403
);
};
export const errorApiKeyNotConfigured = (): OpenViduMeetError => {
return new OpenViduMeetError(
'Authentication Error',

View File

@ -1,9 +1,9 @@
import { MeetRoomMemberRoleAndPermissions, MeetUser } from '@openvidu-meet/typings';
import { MeetRoomMemberTokenMetadata, MeetUser } from '@openvidu-meet/typings';
/**
* Context information stored per HTTP request.
*/
export interface RequestContext {
user?: MeetUser;
roomMember?: MeetRoomMemberRoleAndPermissions;
roomMember?: MeetRoomMemberTokenMetadata;
}

View File

@ -1,4 +1,9 @@
import { LiveKitPermissions, MeetPermissions, MeetRoomMemberRole, MeetUser } from '@openvidu-meet/typings';
import {
MeetRoomMemberPermissions,
MeetRoomMemberRole,
MeetRoomMemberTokenMetadata,
MeetUser
} from '@openvidu-meet/typings';
import { AsyncLocalStorage } from 'async_hooks';
import { injectable } from 'inversify';
import { RequestContext } from '../models/request-context.model.js';
@ -66,58 +71,41 @@ export class RequestSessionService {
/**
* Gets the authenticated user from the current request context.
*/
getUser(): MeetUser | undefined {
getAuthenticatedUser(): MeetUser | undefined {
return this.getContext()?.user;
}
/**
* Sets the room member token information (role, permissions, and token claims)
* Sets the room member token metadata (room ID, base role, permissions)
* in the current request context.
* If called outside a request context, this operation is silently ignored.
*/
setRoomMemberTokenInfo(
role: MeetRoomMemberRole,
meetPermissions: MeetPermissions,
livekitPermissions: LiveKitPermissions
): void {
setRoomMemberTokenMetadata(metadata: MeetRoomMemberTokenMetadata): void {
const context = this.getContext();
if (context) {
context.roomMember = {
role,
permissions: {
meet: meetPermissions,
livekit: livekitPermissions
}
};
context.roomMember = metadata;
}
}
/**
* Gets the room member role from the current request context.
* Gets the room ID to which the room member belongs from the current request context.
*/
getRoomMemberRole(): MeetRoomMemberRole | undefined {
return this.getContext()?.roomMember?.role;
getRoomIdFromMember(): string | undefined {
return this.getContext()?.roomMember?.roomId;
}
/**
* Gets the room member Meet permissions from the current request context.
* Gets the room member base role from the current request context.
*/
getRoomMemberMeetPermissions(): MeetPermissions | undefined {
return this.getContext()?.roomMember?.permissions.meet;
getRoomMemberBaseRole(): MeetRoomMemberRole | undefined {
return this.getContext()?.roomMember?.baseRole;
}
/**
* Gets the room member LiveKit permissions from the current request context.
* Gets the room member effective 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;
getRoomMemberPermissions(): MeetRoomMemberPermissions | undefined {
return this.getContext()?.roomMember?.effectivePermissions;
}
}

View File

@ -69,8 +69,8 @@ export class TokenService {
}
async verifyToken(token: string): Promise<ClaimGrants> {
const verifyer = new TokenVerifier(MEET_ENV.LIVEKIT_API_KEY, MEET_ENV.LIVEKIT_API_SECRET);
return await verifyer.verify(token, 0);
const verifier = new TokenVerifier(MEET_ENV.LIVEKIT_API_KEY, MEET_ENV.LIVEKIT_API_SECRET);
return await verifier.verify(token, 0);
}
/**

View File

@ -1,6 +1,5 @@
import { MeetUser, MeetUserDTO, MeetUserRole } from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { INTERNAL_CONFIG } from '../config/internal-config.js';
import { MEET_ENV } from '../environment.js';
import { PasswordHelper } from '../helpers/password.helper.js';
import { errorInvalidPassword, internalError } from '../models/error.model.js';
@ -27,17 +26,18 @@ export class UserService {
}
const admin: MeetUser = {
username: MEET_ENV.INITIAL_ADMIN_USER,
passwordHash: await PasswordHelper.hashPassword(MEET_ENV.INITIAL_ADMIN_PASSWORD),
roles: [MeetUserRole.ADMIN, MeetUserRole.USER]
userId: MEET_ENV.INITIAL_ADMIN_USER,
name: 'Admin',
role: MeetUserRole.ADMIN,
passwordHash: await PasswordHelper.hashPassword(MEET_ENV.INITIAL_ADMIN_PASSWORD)
};
await this.userRepository.create(admin);
this.logger.info(`Admin user initialized with default credentials`);
}
async authenticateUser(username: string, password: string): Promise<MeetUser | null> {
const user = await this.getUser(username);
async authenticateUser(userId: string, password: string): Promise<MeetUser | null> {
const user = await this.getUser(userId);
if (!user || !(await PasswordHelper.verifyPassword(password, user.passwordHash))) {
return null;
@ -46,24 +46,13 @@ export class UserService {
return user;
}
async getUser(username: string): Promise<MeetUser | null> {
return this.userRepository.findByUsername(username);
async getUser(userId: string): Promise<MeetUser | null> {
return this.userRepository.findByUsername(userId);
}
getAnonymousUser(): MeetUser {
return {
username: INTERNAL_CONFIG.ANONYMOUS_USER,
passwordHash: '',
roles: [MeetUserRole.USER]
};
}
getApiUser(): MeetUser {
return {
username: INTERNAL_CONFIG.API_USER,
passwordHash: '',
roles: [MeetUserRole.APP]
};
async getUserAssociatedWithApiKey(): Promise<MeetUser | null> {
// Return admin user for API key access
return this.userRepository.findByUsername(MEET_ENV.INITIAL_ADMIN_USER);
}
async changePassword(username: string, currentPassword: string, newPassword: string) {