backend: streamline authentication validators and improve error handling

This commit is contained in:
juancarmore 2025-11-06 17:48:46 +01:00
parent e6d04aca16
commit 3fdf2144ab
2 changed files with 177 additions and 144 deletions

View File

@ -14,7 +14,6 @@ import {
errorInvalidToken,
errorInvalidTokenSubject,
errorUnauthorized,
internalError,
OpenViduMeetError,
rejectRequestFromMeetError
} from '../models/index.js';
@ -28,137 +27,183 @@ import {
} from '../services/index.js';
import { getAccessToken, getParticipantToken, getRecordingToken } from '../utils/index.js';
/**
* Interface for authentication validators.
* Each validator must implement methods to check if credentials are present and to validate them.
*/
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.
*/
isPresent(req: Request): Promise<boolean>;
/**
* Validates the authentication credentials and sets the session.
*/
validate(req: Request): Promise<void>;
}
/**
* This middleware allows to chain multiple validators to check if the request is authorized.
* If any of the validators grants access, the request is allowed to continue, skipping the rest of the validators.
* If none of the validators grants access, the request is rejected with an unauthorized error.
* First checks which authentication methods are present in the request, then validates only those methods.
* If any of the validators grants access, the request is allowed to continue.
* If none of the validators grants access, the request is rejected with the most recent error.
*
* @param validators List of validators to check if the request is authorized
* @returns RequestHandler middleware
*/
export const withAuth = (...validators: ((req: Request) => Promise<void>)[]): RequestHandler => {
export const withAuth = (...validators: AuthValidator[]): RequestHandler => {
return async (req: Request, res: Response, next: NextFunction) => {
let lastError: OpenViduMeetError | null = null;
for (const validator of validators) {
try {
await validator(req);
// If any middleware granted access, it is not necessary to continue checking the rest
return next();
if (await validator.isPresent(req)) {
await validator.validate(req);
// If any validator grants access, allow the request to continue
return next();
}
} catch (error) {
if (isErrorWithControl(error)) {
lastError = error.error;
if (error.stopValidation) {
// Stop checking other validators
break;
}
if (error instanceof OpenViduMeetError) {
lastError = error;
}
}
}
if (lastError) {
return rejectRequestFromMeetError(res, lastError);
}
const error = internalError('authenticating user');
return rejectRequestFromMeetError(res, error);
lastError = lastError || errorUnauthorized();
return rejectRequestFromMeetError(res, lastError);
};
};
// Configure token validatior for role-based access
export const tokenAndRoleValidator = (role: UserRole) => {
return async (req: Request) => {
const token = await getAccessToken(req);
/**
* Token and role validator for role-based access.
* Validates JWT tokens and checks if the user has at least one of the required roles.
*
* @param roles One or more roles that are allowed to access the resource
*/
export const tokenAndRoleValidator = (...roles: UserRole[]): AuthValidator => {
return {
async isPresent(req: Request): Promise<boolean> {
const token = await getAccessToken(req);
return !!token;
},
if (!token) {
throw errorWithControl(errorUnauthorized(), false);
async validate(req: Request): Promise<void> {
const token = await getAccessToken(req);
if (!token) {
throw errorUnauthorized();
}
const tokenService = container.get(TokenService);
let payload: ClaimGrants;
try {
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;
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) {
throw errorInsufficientPermissions();
}
req.session = req.session || {};
req.session.user = user;
}
};
};
/**
* Participant token validator for room access.
* Validates participant tokens and checks role permissions.
*/
export const participantTokenValidator: AuthValidator = {
async isPresent(req: Request): Promise<boolean> {
const token = await getParticipantToken(req);
return !!token;
},
async validate(req: Request): Promise<void> {
const token = await getParticipantToken(req);
await validateTokenAndSetSession(req, token);
// 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();
}
const tokenService = container.get(TokenService);
let payload: ClaimGrants;
// Check that the specified role is present in the token claims
let metadata: MeetTokenMetadata;
try {
payload = await tokenService.verifyToken(token);
const participantService = container.get(ParticipantService);
metadata = participantService.parseMetadata(req.session?.tokenClaims?.metadata || '{}');
} catch (error) {
throw errorWithControl(errorInvalidToken(), true);
const logger = container.get(LoggerService);
logger.error('Invalid participant token:', error);
throw errorInvalidParticipantToken();
}
const username = payload.sub;
const userService = container.get(UserService);
const user = username ? await userService.getUser(username) : null;
const roles = metadata.roles;
const hasRole = roles.some(
(r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === participantRole
);
if (!user) {
throw errorWithControl(errorInvalidTokenSubject(), true);
if (!hasRole) {
throw errorInsufficientPermissions();
}
if (!user.roles.includes(role)) {
throw errorWithControl(errorInsufficientPermissions(), false);
}
req.session = req.session || {};
req.session.user = user;
};
// Set the participant role in the session
req.session!.participantRole = participantRole as ParticipantRole;
}
};
// Configure token validator for participant access
export const participantTokenValidator = async (req: Request) => {
const token = await getParticipantToken(req);
await validateTokenAndSetSession(req, token);
/**
* 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;
},
// 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];
async validate(req: Request): Promise<void> {
const token = await getRecordingToken(req);
await validateTokenAndSetSession(req, token);
if (!participantRole || !allRoles.includes(participantRole as ParticipantRole)) {
throw errorWithControl(errorInvalidParticipantRole(), true);
}
// Check that the specified role is present in the token claims
let metadata: MeetTokenMetadata;
try {
const participantService = container.get(ParticipantService);
metadata = participantService.parseMetadata(req.session?.tokenClaims?.metadata || '{}');
} catch (error) {
const logger = container.get(LoggerService);
logger.error('Invalid participant token:', error);
throw errorWithControl(errorInvalidParticipantToken(), true);
}
const roles = metadata.roles;
const hasRole = roles.some(
(r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === participantRole
);
if (!hasRole) {
throw errorWithControl(errorInsufficientPermissions(), true);
}
// Set the participant role in the session
req.session!.participantRole = participantRole as ParticipantRole;
};
// Configure token validator for recording access
export const recordingTokenValidator = async (req: Request) => {
const token = await getRecordingToken(req);
await validateTokenAndSetSession(req, token);
// Validate the recording token metadata
try {
const roomService = container.get(RoomService);
roomService.parseRecordingTokenMetadata(req.session?.tokenClaims?.metadata || '{}');
} catch (error) {
const logger = container.get(LoggerService);
logger.error('Invalid recording token:', error);
throw errorWithControl(errorInvalidRecordingToken(), true);
// Validate the recording token metadata
try {
const roomService = container.get(RoomService);
roomService.parseRecordingTokenMetadata(req.session?.tokenClaims?.metadata || '{}');
} catch (error) {
const logger = container.get(LoggerService);
logger.error('Invalid recording token:', error);
throw errorInvalidRecordingToken();
}
}
};
const validateTokenAndSetSession = async (req: Request, token: string | undefined) => {
if (!token) {
throw errorWithControl(errorUnauthorized(), false);
throw errorUnauthorized();
}
const tokenService = container.get(TokenService);
@ -172,48 +217,58 @@ const validateTokenAndSetSession = async (req: Request, token: string | undefine
req.session.tokenClaims = payload;
req.session.user = user;
} catch (error) {
throw errorWithControl(errorInvalidToken(), true);
throw errorInvalidToken();
}
};
// Configure API key validatior
export const apiKeyValidator = async (req: Request) => {
const apiKey = req.headers[INTERNAL_CONFIG.API_KEY_HEADER];
/**
* API key validator for service-to-service authentication.
* Validates API keys from request headers.
*/
export const apiKeyValidator: AuthValidator = {
async isPresent(req: Request): Promise<boolean> {
const apiKey = req.headers[INTERNAL_CONFIG.API_KEY_HEADER];
return !!apiKey;
},
if (!apiKey) {
throw errorWithControl(errorUnauthorized(), false);
}
async validate(req: Request): Promise<void> {
const apiKey = req.headers[INTERNAL_CONFIG.API_KEY_HEADER];
if (!apiKey) {
throw errorUnauthorized();
}
try {
const apiKeyService = container.get(ApiKeyService);
const isValidApiKey = await apiKeyService.validateApiKey(apiKey as string);
if (!isValidApiKey) {
throw errorInvalidApiKey();
}
} catch (error) {
if (error instanceof OpenViduMeetError) {
throw errorWithControl(error, true);
} else {
const logger = container.get(LoggerService);
logger.error('Error validating API key:', error);
throw errorWithControl(internalError('validating API key'), true);
}
const userService = container.get(UserService);
const apiUser = userService.getApiUser();
req.session = req.session || {};
req.session.user = apiUser;
}
const userService = container.get(UserService);
const apiUser = userService.getApiUser();
req.session = req.session || {};
req.session.user = apiUser;
};
// Allow anonymous access
export const allowAnonymous = async (req: Request) => {
const user = await getAuthenticatedUserOrAnonymous(req);
/**
* Anonymous access validator.
* Always present and allows unauthenticated access with an anonymous user.
*/
export const allowAnonymous: AuthValidator = {
async isPresent(): Promise<boolean> {
// Anonymous access is always available
return true;
},
req.session = req.session || {};
req.session.user = user;
async validate(req: Request): Promise<void> {
const user = await getAuthenticatedUserOrAnonymous(req);
req.session = req.session || {};
req.session.user = user;
}
};
// Return the authenticated user if available, otherwise return an anonymous user
@ -259,21 +314,3 @@ export const withLoginLimiter = (req: Request, res: Response, next: NextFunction
return loginLimiter(req, res, next);
};
// OpenViduMeetError with control to stop checking other validators
interface ErrorWithControl {
error: OpenViduMeetError;
stopValidation: boolean;
}
const errorWithControl = (error: OpenViduMeetError, stopValidation: boolean): ErrorWithControl => {
const errorWithControl: ErrorWithControl = {
error,
stopValidation
};
return errorWithControl;
};
const isErrorWithControl = (error: unknown): error is ErrorWithControl => {
return typeof error === 'object' && error !== null && 'error' in error && 'stopValidation' in error;
};

View File

@ -9,14 +9,10 @@ userRouter.use(bodyParser.urlencoded({ extended: true }));
userRouter.use(bodyParser.json());
// Users Routes
userRouter.get(
'/profile',
withAuth(tokenAndRoleValidator(UserRole.ADMIN), tokenAndRoleValidator(UserRole.USER)),
userCtrl.getProfile
);
userRouter.get('/profile', withAuth(tokenAndRoleValidator(UserRole.ADMIN, UserRole.USER)), userCtrl.getProfile);
userRouter.post(
'/change-password',
withAuth(tokenAndRoleValidator(UserRole.ADMIN), tokenAndRoleValidator(UserRole.USER)),
withAuth(tokenAndRoleValidator(UserRole.ADMIN, UserRole.USER)),
validateChangePasswordRequest,
userCtrl.changePassword
);