Implement authentication transport modes for JWT tokens

- Added AuthTransportMode enum to define COOKIE and HEADER modes.
- Updated AuthenticationConfig interface to include authTransportMode.
- Refactored token handling in participant and recording services to support header-based authentication.
- Introduced TokenStorageService for managing JWT tokens in localStorage and sessionStorage.
- Modified middleware and controllers to utilize new token extraction methods based on transport mode.
- Updated frontend services and components to handle token storage and retrieval according to the selected transport mode.
- Enhanced error handling and logging for authentication processes.
This commit is contained in:
juancarmore 2025-10-09 19:13:08 +02:00
parent 5d8343d75d
commit 0cab67eb65
24 changed files with 567 additions and 157 deletions

View File

@ -11,6 +11,14 @@ const INTERNAL_CONFIG = {
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',
// Token expiration times
ACCESS_TOKEN_EXPIRATION: '2h',
REFRESH_TOKEN_EXPIRATION: '1d',
@ -21,10 +29,6 @@ const INTERNAL_CONFIG = {
PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS: '20', // Maximum number of request by the same name at the same time allowed
PARTICIPANT_NAME_RESERVATION_TTL: '12h' as StringValue, // Time-to-live for participant name reservations
// Headers for API requests
API_KEY_HEADER: 'x-api-key',
PARTICIPANT_ROLE_HEADER: 'x-participant-role',
// Authentication usernames
ANONYMOUS_USER: 'anonymous',
API_USER: 'api-user',

View File

@ -1,3 +1,4 @@
import { AuthTransportMode } from '@typings-ce';
import { Request, Response } from 'express';
import { ClaimGrants } from 'livekit-server-sdk';
import { container } from '../config/index.js';
@ -11,7 +12,7 @@ import {
rejectRequestFromMeetError
} from '../models/error.model.js';
import { AuthService, LoggerService, TokenService, UserService } from '../services/index.js';
import { getCookieOptions } from '../utils/index.js';
import { getAuthTransportMode, getCookieOptions, getRefreshToken } from '../utils/index.js';
export const login = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
@ -31,6 +32,19 @@ export const login = async (req: Request, res: Response) => {
const tokenService = container.get(TokenService);
const accessToken = await tokenService.generateAccessToken(user);
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,
@ -44,25 +58,34 @@ export const login = async (req: Request, res: Response) => {
INTERNAL_CONFIG.REFRESH_TOKEN_EXPIRATION
)
);
logger.info(`Login succeeded for user '${username}'`);
return res.status(200).json({ message: `User '${username}' logged in successfully` });
}
} catch (error) {
handleError(res, error, 'generating token');
handleError(res, error, 'generating access and refresh tokens');
}
};
export const logout = (_req: Request, res: Response) => {
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
return res.status(200).json({ message: 'Logout successful' });
};
export const refreshToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
logger.verbose('Refresh token request received');
const refreshToken = req.cookies[INTERNAL_CONFIG.REFRESH_TOKEN_COOKIE_NAME];
// Get refresh token from cookie or header based on transport mode
const refreshToken = await getRefreshToken(req);
if (!refreshToken) {
logger.warn('No refresh token provided');
@ -93,13 +116,25 @@ export const refreshToken = async (req: Request, res: Response) => {
try {
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)
);
logger.info(`Access token refreshed for user '${username}'`);
return res.status(200).json({ message: `Access token for user '${username}' successfully refreshed` });
}
} catch (error) {
handleError(res, error, 'refreshing token');
}

View File

@ -13,11 +13,7 @@ export const updateSecurityConfig = async (req: Request, res: Response) => {
try {
const globalConfig = await storageService.getGlobalConfig();
const currentAuth = globalConfig.securityConfig.authentication;
const newAuth = securityConfig.authentication;
currentAuth.authMethod = newAuth.authMethod;
currentAuth.authModeToAccessRoom = newAuth.authModeToAccessRoom;
globalConfig.securityConfig.authentication = { ...securityConfig.authentication };
await storageService.saveGlobalConfig(globalConfig);
return res.status(200).json({ message: 'Security config updated successfully' });

View File

@ -1,4 +1,4 @@
import { OpenViduMeetPermissions, ParticipantOptions, ParticipantRole } from '@typings-ce';
import { AuthTransportMode, OpenViduMeetPermissions, ParticipantOptions, ParticipantRole } from '@typings-ce';
import { Request, Response } from 'express';
import { container } from '../config/index.js';
import INTERNAL_CONFIG from '../config/internal-config.js';
@ -9,23 +9,25 @@ import {
rejectRequestFromMeetError
} from '../models/error.model.js';
import { LoggerService, ParticipantService, RoomService, TokenService } from '../services/index.js';
import { getCookieOptions } from '../utils/index.js';
import { getAuthTransportMode, getCookieOptions, getRecordingToken } 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
// 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');
const tokenService = container.get(TokenService);
try {
const claims = tokenService.getClaimsIgnoringExpiration(previousToken);
@ -38,9 +40,15 @@ export const generateParticipantToken = async (req: Request, res: Response) => {
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}'`);
@ -49,9 +57,11 @@ export const generateParticipantToken = async (req: Request, res: Response) => {
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 = req.cookies[INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME];
const previousToken = await getRecordingToken(req);
if (!previousToken) {
logger.verbose('No previous participant token found. Cannot refresh.');
@ -60,8 +70,6 @@ export const refreshParticipantToken = async (req: Request, res: Response) => {
}
// Extract roles from the previous token
const tokenService = container.get(TokenService);
const participantService = container.get(ParticipantService);
let currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] = [];
try {
@ -85,7 +93,13 @@ export const refreshParticipantToken = async (req: Request, res: Response) => {
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}'`);

View File

@ -1,4 +1,5 @@
import {
AuthTransportMode,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomDeletionSuccessCode,
@ -12,7 +13,7 @@ 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 { getBaseUrl, getCookieOptions } from '../utils/index.js';
import { getAuthTransportMode, getBaseUrl, getCookieOptions } from '../utils/index.js';
export const createRoom = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
@ -192,12 +193,17 @@ export const generateRecordingToken = async (req: Request, res: Response) => {
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)
);
}
return res.status(200).json({ token });
} catch (error) {
handleError(res, error, `generating recording token for room '${roomId}'`);

View File

@ -26,6 +26,7 @@ import {
TokenService,
UserService
} from '../services/index.js';
import { getAccessToken, getParticipantToken, getRecordingToken } from '../utils/index.js';
/**
* This middleware allows to chain multiple validators to check if the request is authorized.
@ -68,7 +69,7 @@ export const withAuth = (...validators: ((req: Request) => Promise<void>)[]): Re
// Configure token validatior for role-based access
export const tokenAndRoleValidator = (role: UserRole) => {
return async (req: Request) => {
const token = req.cookies[INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME];
const token = await getAccessToken(req);
if (!token) {
throw errorWithControl(errorUnauthorized(), false);
@ -102,7 +103,8 @@ export const tokenAndRoleValidator = (role: UserRole) => {
// Configure token validator for participant access
export const participantTokenValidator = async (req: Request) => {
await validateTokenAndSetSession(req, INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME);
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
@ -140,7 +142,8 @@ export const participantTokenValidator = async (req: Request) => {
// Configure token validator for recording access
export const recordingTokenValidator = async (req: Request) => {
await validateTokenAndSetSession(req, INTERNAL_CONFIG.RECORDING_TOKEN_COOKIE_NAME);
const token = await getRecordingToken(req);
await validateTokenAndSetSession(req, token);
// Validate the recording token metadata
try {
@ -153,9 +156,7 @@ export const recordingTokenValidator = async (req: Request) => {
}
};
const validateTokenAndSetSession = async (req: Request, cookieName: string) => {
const token = req.cookies[cookieName];
const validateTokenAndSetSession = async (req: Request, token: string | undefined) => {
if (!token) {
throw errorWithControl(errorUnauthorized(), false);
}
@ -221,7 +222,7 @@ const getAuthenticatedUserOrAnonymous = async (req: Request): Promise<User> => {
let user: User | null = null;
// Check if there is a user already authenticated
const token = req.cookies[INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME];
const token = await getAccessToken(req);
if (token) {
try {

View File

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

View File

@ -1,4 +1,14 @@
import { AuthMode, AuthType, GlobalConfig, MeetApiKey, MeetRecordingInfo, MeetRoom, User, UserRole } from '@typings-ce';
import {
AuthMode,
AuthTransportMode,
AuthType,
GlobalConfig,
MeetApiKey,
MeetRecordingInfo,
MeetRoom,
User,
UserRole
} from '@typings-ce';
import { inject, injectable } from 'inversify';
import ms from 'ms';
import { Readable } from 'stream';
@ -722,7 +732,8 @@ export class MeetStorageService<
authMethod: {
type: AuthType.SINGLE_USER
},
authModeToAccessRoom: AuthMode.NONE
authModeToAccessRoom: AuthMode.NONE,
authTransportMode: AuthTransportMode.COOKIE
}
},
roomsConfig: {

View File

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

View File

@ -0,0 +1,121 @@
import { AuthTransportMode } from '@typings-ce';
import { Request } from 'express';
import { container } from '../config/index.js';
import INTERNAL_CONFIG from '../config/internal-config.js';
import { LoggerService, MeetStorageService } 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 storageService = container.get(MeetStorageService);
const globalConfig = await storageService.getGlobalConfig();
return globalConfig.securityConfig.authentication.authTransportMode;
} catch (error) {
const logger = container.get(LoggerService);
logger.error('Error fetching auth transport mode:', error);
// Fallback to cookie mode in case of error
return AuthTransportMode.COOKIE;
}
};
/**
* Extracts the access token from the request based on the configured transport mode.
*
* @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'
);
};
/**
* Extracts the refresh token from the request based on the configured transport mode.
*
* @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);
};
/**
* Extracts the participant 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
*/
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'
);
};
/**
* Generic function to extract a JWT token from the request based on the configured transport mode.
*
* @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)
* @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];
}
// Try to get from header
const headerValue = req.headers[headerName];
// Header value must be a string starting with 'Bearer '
if (headerValue && typeof headerValue === 'string' && headerValue.startsWith('Bearer ')) {
return headerValue.substring(7);
}
/**
* If not found in header, try to get from query parameter
* This is needed to send access/recording tokens via URL for video playback
* since we cannot set custom headers in video element requests
*/
if (queryParamName) {
const token = req.query[queryParamName];
if (token && typeof token === 'string') {
return token;
}
}
};

View File

@ -17,7 +17,7 @@ export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRoute
const sessionStorageService = inject(SessionStorageService);
const { roomId, secret: querySecret, participantName, leaveRedirectUrl, showOnlyRecordings } = extractParams(route);
const secret = querySecret || sessionStorageService.getRoomSecret(roomId);
const secret = querySecret || sessionStorageService.getRoomSecret();
// Handle leave redirect URL logic
handleLeaveRedirectUrl(leaveRedirectUrl);
@ -48,7 +48,7 @@ export const extractRecordingQueryParamsGuard: CanActivateFn = (route: Activated
const sessionStorageService = inject(SessionStorageService);
const { roomId, secret: querySecret } = extractParams(route);
const secret = querySecret || sessionStorageService.getRoomSecret(roomId);
const secret = querySecret || sessionStorageService.getRoomSecret();
if (!secret) {
// If no secret is provided, redirect to the error page
@ -83,9 +83,8 @@ const handleLeaveRedirectUrl = (leaveRedirectUrl: string | undefined) => {
return;
}
// Absolute path provided in embedded mode - construct full URL
// Absolute path provided in embedded mode - construct full URL based on parent origin
if (isEmbeddedMode && leaveRedirectUrl?.startsWith('/')) {
// If in embedded mode and a absolute path is provided, construct full URL based on parent origin
const parentUrl = document.referrer;
const parentOrigin = new URL(parentUrl).origin;
navigationService.setLeaveRedirectUrl(parentOrigin + leaveRedirectUrl);

View File

@ -1,28 +1,68 @@
import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService, ParticipantService, RecordingService, RoomService } from '@lib/services';
import { AuthService, ParticipantService, RecordingService, RoomService, TokenStorageService } from '@lib/services';
import { catchError, from, Observable, switchMap } from 'rxjs';
/**
* Adds all necessary authorization headers to the request based on available tokens
* - authorization: Bearer token for access token (from localStorage)
* - x-participant-token: Bearer token for participant token (from sessionStorage)
* - x-recording-token: Bearer token for recording token (from sessionStorage)
*/
const addAuthHeadersIfNeeded = (
req: HttpRequest<unknown>,
tokenStorageService: TokenStorageService
): HttpRequest<unknown> => {
const headers: { [key: string]: string } = {};
// Add access token header if available
const accessToken = tokenStorageService.getAccessToken();
if (accessToken) {
headers['authorization'] = `Bearer ${accessToken}`;
}
// Add participant token header if available
const participantToken = tokenStorageService.getParticipantToken();
if (participantToken) {
headers['x-participant-token'] = `Bearer ${participantToken}`;
}
// Add recording token header if available
const recordingToken = tokenStorageService.getRecordingToken();
if (recordingToken) {
headers['x-recording-token'] = `Bearer ${recordingToken}`;
}
// Clone request with all headers at once if any were added
return Object.keys(headers).length > 0 ? req.clone({ setHeaders: headers }) : req;
};
export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
const router: Router = inject(Router);
const authService: AuthService = inject(AuthService);
const roomService = inject(RoomService);
const participantTokenService = inject(ParticipantService);
const recordingService = inject(RecordingService);
const tokenStorageService = inject(TokenStorageService);
const pageUrl = router.currentNavigation()?.finalUrl?.toString() || router.url;
const requestUrl = req.url;
// Clone request with credentials for cookie mode
req = req.clone({
withCredentials: true
});
// Add all authorization headers if tokens exist
req = addAuthHeadersIfNeeded(req, tokenStorageService);
const refreshAccessToken = (firstError: HttpErrorResponse) => {
console.log('Refreshing access token...');
return authService.refreshToken().pipe(
return from(authService.refreshToken()).pipe(
switchMap(() => {
console.log('Access token refreshed');
req = addAuthHeadersIfNeeded(req, tokenStorageService);
return next(req);
}),
catchError(async (error: HttpErrorResponse) => {
@ -55,6 +95,7 @@ export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, ne
).pipe(
switchMap(() => {
console.log('Participant token refreshed');
req = addAuthHeadersIfNeeded(req, tokenStorageService);
return next(req);
}),
catchError((error: HttpErrorResponse) => {
@ -76,6 +117,7 @@ export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, ne
return from(recordingService.generateRecordingToken(roomId, secret)).pipe(
switchMap(() => {
console.log('Recording token refreshed');
req = addAuthHeadersIfNeeded(req, tokenStorageService);
return next(req);
}),
catchError((error: HttpErrorResponse) => {

View File

@ -243,9 +243,7 @@ export class ConfigComponent implements OnInit {
if (!this.initialFormValue) return;
this.handleThemeChange();
this.updateColorChangesState();
this.updateFormChangeState();
}
@ -259,9 +257,9 @@ export class ConfigComponent implements OnInit {
* Handles theme change by updating non-customized colors
*/
private handleThemeChange(): void {
const currentFormValue = this.appearanceForm.value;
const newBaseTheme = currentFormValue.baseTheme || MeetRoomThemeMode.LIGHT;
const newBaseTheme = this.appearanceForm.value.baseTheme || MeetRoomThemeMode.LIGHT;
if (newBaseTheme === this.lastBaseThemeValue) return;
const newDefaults = this.defaultColors[newBaseTheme];
// Build update object with only non-customized colors
@ -276,6 +274,7 @@ export class ConfigComponent implements OnInit {
if (Object.keys(updatedColors).length === 0) {
return;
}
// Apply updates atomically
this.applyColorUpdates(updatedColors, newBaseTheme);
}
@ -294,11 +293,8 @@ export class ConfigComponent implements OnInit {
* Updates color changes state efficiently
*/
private updateColorChangesState(): void {
const currentFormValue = this.appearanceForm.value;
const colorKeys: ColorField[] = this.colorFields.map((field) => field.key);
const hasColorChanges = colorKeys.some((key) => currentFormValue[key] !== this.initialFormValue![key]);
const hasColorChanges = colorKeys.some((key) => this.appearanceForm.value[key] !== this.initialFormValue![key]);
this.hasColorChanges.set(hasColorChanges);
}

View File

@ -28,6 +28,7 @@ import {
RecordingService,
RoomService,
SessionStorageService,
TokenStorageService,
WebComponentManagerService
} from '@lib/services';
import {
@ -130,7 +131,8 @@ export class MeetingComponent implements OnInit {
protected clipboard: Clipboard,
protected viewportService: ViewportService,
protected ovThemeService: OpenViduThemeService,
protected configService: GlobalConfigService
protected configService: GlobalConfigService,
protected tokenStorageService: TokenStorageService
) {
this.features = this.featureConfService.features;
@ -488,9 +490,12 @@ export class MeetingComponent implements OnInit {
};
this.wcManagerService.sendMessageToParent(message);
// Remove the moderator secret from session storage if the participant left for a reason other than browser unload
// Remove the moderator secret (and stored tokens) from session storage
// if the participant left for a reason other than browser unload
if (event.reason !== ParticipantLeftReason.BROWSER_UNLOAD) {
this.sessionStorageService.removeRoomSecret(event.roomName);
this.sessionStorageService.removeRoomSecret();
this.tokenStorageService.clearParticipantToken();
this.tokenStorageService.clearRecordingToken();
}
// Navigate to the disconnected page with the reason

View File

@ -138,6 +138,4 @@ export class ViewRecordingComponent implements OnInit {
this.router.navigate(['/recordings']);
}
}
}

View File

@ -1,6 +1,6 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { HttpService, NavigationService } from '@lib/services';
import { HttpService, NavigationService, TokenStorageService } from '@lib/services';
import { MeetApiKey, User, UserRole } from '@lib/typings/ce';
import { from, Observable } from 'rxjs';
@ -16,6 +16,7 @@ export class AuthService {
constructor(
protected httpService: HttpService,
protected tokenStorageService: TokenStorageService,
protected navigationService: NavigationService
) {}
@ -23,7 +24,14 @@ export class AuthService {
try {
const path = `${this.AUTH_API}/login`;
const body = { username, password };
await this.httpService.postRequest(path, body);
const response = await this.httpService.postRequest<any>(path, body);
// Check if we got tokens in the response (header mode)
if (response.accessToken && response.refreshToken) {
this.tokenStorageService.setAccessToken(response.accessToken);
this.tokenStorageService.setRefreshToken(response.refreshToken);
}
await this.getAuthenticatedUser(true);
} catch (err) {
const error = err as HttpErrorResponse;
@ -32,10 +40,25 @@ export class AuthService {
}
}
refreshToken(): Observable<any> {
async refreshToken() {
const path = `${this.AUTH_API}/refresh`;
const response = this.httpService.postRequest(path);
return from(response);
const refreshToken = this.tokenStorageService.getRefreshToken();
// Add refresh token header if in header mode
let headers: Record<string, string> | undefined;
if (refreshToken) {
headers = {};
headers['x-refresh-token'] = `Bearer ${refreshToken}`;
}
const response = await this.httpService.postRequest<any>(path, {}, headers);
// Update access token in localStorage if returned in response
if (response.accessToken) {
this.tokenStorageService.setAccessToken(response.accessToken);
}
return response;
}
async logout(redirectToAfterLogin?: string) {
@ -44,6 +67,9 @@ export class AuthService {
await this.httpService.postRequest(path);
this.user = null;
// Clear tokens from localStorage if in header mode
this.tokenStorageService.clearAccessAndRefreshTokens();
// Redirect to login page with a query parameter if provided
// to redirect to the original page after login
this.navigationService.redirectToLoginPage(redirectToAfterLogin, true);

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { FeatureConfigurationService, HttpService } from '@lib/services';
import { AuthMode, MeetAppearanceConfig, SecurityConfig, WebhookConfig } from '@lib/typings/ce';
import { AuthMode, AuthTransportMode, MeetAppearanceConfig, SecurityConfig, WebhookConfig } from '@lib/typings/ce';
import { LoggerService } from 'openvidu-components-angular';
@Injectable({
@ -41,6 +41,11 @@ export class GlobalConfigService {
return this.securityConfig!.authentication.authModeToAccessRoom;
}
async getAuthTransportMode(): Promise<AuthTransportMode> {
await this.getSecurityConfig();
return this.securityConfig!.authentication.authTransportMode;
}
async saveSecurityConfig(config: SecurityConfig) {
const path = `${this.GLOBAL_CONFIG_API}/security`;
await this.httpService.putRequest(path, config);

View File

@ -12,4 +12,5 @@ export * from './navigation.service';
export * from './notification.service';
export * from './session-storage.service';
export * from './theme.service';
export * from './token-storage.service';
export * from './wizard-state.service';

View File

@ -1,6 +1,12 @@
import { Injectable } from '@angular/core';
import { FeatureConfigurationService, HttpService } from '@lib/services';
import { MeetTokenMetadata, ParticipantOptions, ParticipantPermissions, ParticipantRole } from '@lib/typings/ce';
import { FeatureConfigurationService, GlobalConfigService, HttpService, TokenStorageService } from '@lib/services';
import {
AuthTransportMode,
MeetTokenMetadata,
ParticipantOptions,
ParticipantPermissions,
ParticipantRole
} from '@lib/typings/ce';
import { getValidDecodedToken } from '@lib/utils';
import { LoggerService } from 'openvidu-components-angular';
@ -21,7 +27,9 @@ export class ParticipantService {
constructor(
protected loggerService: LoggerService,
protected httpService: HttpService,
protected featureConfService: FeatureConfigurationService
protected featureConfService: FeatureConfigurationService,
protected globalConfigService: GlobalConfigService,
protected tokenStorageService: TokenStorageService
) {
this.log = this.loggerService.get('OpenVidu Meet - ParticipantTokenService');
}
@ -49,6 +57,12 @@ export class ParticipantService {
const path = `${this.PARTICIPANTS_API}/token`;
const { token } = await this.httpService.postRequest<{ token: string }>(path, participantOptions);
// Store token in sessionStorage for header mode
const authTransportMode = await this.globalConfigService.getAuthTransportMode();
if (authTransportMode === AuthTransportMode.HEADER) {
this.tokenStorageService.setParticipantToken(token);
}
this.updateParticipantTokenInfo(token);
return token;
}
@ -63,6 +77,12 @@ export class ParticipantService {
const path = `${this.PARTICIPANTS_API}/token/refresh`;
const { token } = await this.httpService.postRequest<{ token: string }>(path, participantOptions);
// Store token in sessionStorage for header mode
const authTransportMode = await this.globalConfigService.getAuthTransportMode();
if (authTransportMode === AuthTransportMode.HEADER) {
this.tokenStorageService.setParticipantToken(token);
}
this.updateParticipantTokenInfo(token);
return token;
}

View File

@ -1,8 +1,15 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ShareRecordingDialogComponent } from '@lib/components';
import { AuthService, FeatureConfigurationService, HttpService, ParticipantService } from '@lib/services';
import { MeetRecordingFilters, MeetRecordingInfo, RecordingPermissions } from '@lib/typings/ce';
import {
AuthService,
FeatureConfigurationService,
GlobalConfigService,
HttpService,
ParticipantService,
TokenStorageService
} from '@lib/services';
import { AuthTransportMode, MeetRecordingFilters, MeetRecordingInfo, RecordingPermissions } from '@lib/typings/ce';
import { getValidDecodedToken } from '@lib/utils';
import { LoggerService } from 'openvidu-components-angular';
@ -26,6 +33,8 @@ export class RecordingService {
protected participantService: ParticipantService,
protected authService: AuthService,
protected featureConfService: FeatureConfigurationService,
protected globalConfigService: GlobalConfigService,
protected tokenStorageService: TokenStorageService,
protected dialog: MatDialog
) {
this.log = this.loggerService.get('OpenVidu Meet - RecordingManagerService');
@ -130,8 +139,21 @@ export class RecordingService {
*/
getRecordingMediaUrl(recordingId: string, secret?: string): string {
const params = new URLSearchParams();
// If secret is provided, use it (public/private access mode)
if (secret) {
params.append('secret', secret);
} else {
// Otherwise, try to use access and/or recording token from sessionStorage (header mode)
const accessToken = this.tokenStorageService.getAccessToken();
if (accessToken) {
params.append('accessToken', accessToken);
}
const recordingToken = this.tokenStorageService.getRecordingToken();
if (recordingToken) {
params.append('recordingToken', recordingToken);
}
}
const now = Date.now();
@ -163,6 +185,13 @@ export class RecordingService {
try {
const { token } = await this.httpService.postRequest<{ token: string }>(path, { secret });
// Store token in sessionStorage for header mode
const authTransportMode = await this.globalConfigService.getAuthTransportMode();
if (authTransportMode === AuthTransportMode.HEADER) {
this.tokenStorageService.setRecordingToken(token);
}
this.setRecordingPermissionsFromToken(token);
return this.recordingPermissions;
} catch (error) {
@ -244,6 +273,7 @@ export class RecordingService {
* Downloads a recording by creating a link and triggering a click event
*
* @param recording - The recording information containing the ID and filename
* @param secret - Optional secret for accessing the recording
*/
downloadRecording(recording: MeetRecordingInfo, secret?: string) {
const recordingUrl = this.getRecordingMediaUrl(recording.recordingId, secret);
@ -256,14 +286,28 @@ export class RecordingService {
/**
* Downloads multiple recordings as a ZIP file
*
* @param recordings - An array of recording IDs to download
* @param recordingIds - An array of recording IDs to download
*/
downloadRecordingsAsZip(recordingIds: string[]) {
if (recordingIds.length === 0) {
throw new Error('No recordings IDs provided for download');
}
const downloadUrl = `${this.RECORDINGS_API}/download?recordingIds=${recordingIds.join(',')}`;
const params = new URLSearchParams();
params.append('recordingIds', recordingIds.join(','));
// Try to add access and/or recording token from sessionStorage (header mode)
const accessToken = this.tokenStorageService.getAccessToken();
if (accessToken) {
params.append('accessToken', accessToken);
}
const recordingToken = this.tokenStorageService.getRecordingToken();
if (recordingToken) {
params.append('recordingToken', recordingToken);
}
const downloadUrl = `${this.RECORDINGS_API}/download?${params.toString()}`;
const link = document.createElement('a');
link.href = downloadUrl;
link.download = 'recordings.zip';

View File

@ -46,7 +46,7 @@ export class RoomService {
setRoomSecret(secret: string, updateStorage = true) {
this.roomSecret = secret;
if (updateStorage) {
this.sessionStorageService.setRoomSecret(this.roomId, secret);
this.sessionStorageService.setRoomSecret(secret);
}
}

View File

@ -8,35 +8,32 @@ import { Injectable } from '@angular/core';
* Provides methods to store, retrieve, and remove data from sessionStorage.
*/
export class SessionStorageService {
constructor() {}
private readonly ROOM_SECRET_KEY = 'ovMeet-roomSecret';
private readonly REDIRECT_URL_KEY = 'ovMeet-redirectUrl';
/**
* Stores a secret associated with a participant role for a specific room.
* Stores the room secret.
*
* @param roomId The room ID.
* @param secret The secret to store.
*/
public setRoomSecret(roomId: string, secret: string): void {
this.set(`room_secret_${roomId}`, secret);
public setRoomSecret(secret: string): void {
this.set(this.ROOM_SECRET_KEY, secret);
}
/**
* Retrieves the room secret for a specific room.
* Retrieves the room secret.
*
* @param roomId The room ID.
* @returns The stored secret or null if not found.
*/
public getRoomSecret(roomId: string): string | null {
return this.get<string>(`room_secret_${roomId}`) ?? null;
public getRoomSecret(): string | null {
return this.get<string>(this.ROOM_SECRET_KEY);
}
/**
* Removes the room secret for a specific room.
*
* @param roomId The room ID.
* Removes the room secret.
*/
public removeRoomSecret(roomId: string): void {
this.remove(`room_secret_${roomId}`);
public removeRoomSecret(): void {
this.remove(this.ROOM_SECRET_KEY);
}
/**
@ -45,7 +42,7 @@ export class SessionStorageService {
* @param redirectUrl The URL to redirect to.
*/
public setRedirectUrl(redirectUrl: string): void {
this.set('redirect_url', redirectUrl);
this.set(this.REDIRECT_URL_KEY, redirectUrl);
}
/**
@ -54,7 +51,7 @@ export class SessionStorageService {
* @returns The redirect URL or null if not found.
*/
public getRedirectUrl(): string | null {
return this.get<string>('redirect_url') ?? null;
return this.get<string>(this.REDIRECT_URL_KEY);
}
/**

View File

@ -0,0 +1,76 @@
import { Injectable } from '@angular/core';
/**
* Service to manage JWT token storage when using header-based authentication.
* Tokens are stored in localStorage/sessionStorage when authTransportMode is 'header'.
*/
@Injectable({
providedIn: 'root'
})
export class TokenStorageService {
private readonly ACCESS_TOKEN_KEY = 'ovMeet-accessToken';
private readonly REFRESH_TOKEN_KEY = 'ovMeet-refreshToken';
private readonly PARTICIPANT_TOKEN_KEY = 'ovMeet-participantToken';
private readonly RECORDING_TOKEN_KEY = 'ovMeet-recordingToken';
// ACCESS AND REFRESH TOKEN METHODS
// Saves the access token to localStorage
setAccessToken(token: string): void {
localStorage.setItem(this.ACCESS_TOKEN_KEY, token);
}
// Retrieves the access token from localStorage
getAccessToken(): string | null {
return localStorage.getItem(this.ACCESS_TOKEN_KEY);
}
// Saves the refresh token to localStorage
setRefreshToken(token: string): void {
localStorage.setItem(this.REFRESH_TOKEN_KEY, token);
}
// Retrieves the refresh token from localStorage
getRefreshToken(): string | null {
return localStorage.getItem(this.REFRESH_TOKEN_KEY);
}
// Clears access and refresh tokens from localStorage
clearAccessAndRefreshTokens(): void {
localStorage.removeItem(this.ACCESS_TOKEN_KEY);
localStorage.removeItem(this.REFRESH_TOKEN_KEY);
}
// PARTICIPANT AND RECORDING TOKEN METHODS
// Uses sessionStorage instead of localStorage to ensure tokens are not shared across browser tabs
// Saves the participant token to sessionStorage
setParticipantToken(token: string): void {
sessionStorage.setItem(this.PARTICIPANT_TOKEN_KEY, token);
}
// Retrieves the participant token from sessionStorage
getParticipantToken(): string | null {
return sessionStorage.getItem(this.PARTICIPANT_TOKEN_KEY);
}
// Removes the participant token from sessionStorage
clearParticipantToken(): void {
sessionStorage.removeItem(this.PARTICIPANT_TOKEN_KEY);
}
// Saves the recording token to sessionStorage
setRecordingToken(token: string): void {
sessionStorage.setItem(this.RECORDING_TOKEN_KEY, token);
}
// Retrieves the recording token from sessionStorage
getRecordingToken(): string | null {
return sessionStorage.getItem(this.RECORDING_TOKEN_KEY);
}
// Removes the recording token from sessionStorage
clearRecordingToken(): void {
sessionStorage.removeItem(this.RECORDING_TOKEN_KEY);
}
}

View File

@ -1,15 +1,24 @@
export interface AuthenticationConfig {
authMethod: ValidAuthMethod;
authTransportMode: AuthTransportMode;
authModeToAccessRoom: AuthMode;
}
/**
* Authentication transport modes for JWT tokens.
*/
export const enum AuthTransportMode {
COOKIE = 'cookie', // JWT sent via cookies
HEADER = 'header' // JWT sent via Authorization header
}
/**
* Authentication modes available to enter a room.
*/
export const enum AuthMode {
NONE = 'none', // No authentication required
MODERATORS_ONLY = 'moderators_only', // Only moderators need authentication
ALL_USERS = 'all_users', // All users need authentication
ALL_USERS = 'all_users' // All users need authentication
}
/**
@ -23,7 +32,7 @@ export interface AuthMethod {
* Enum for authentication types.
*/
export const enum AuthType {
SINGLE_USER = 'single_user',
SINGLE_USER = 'single_user'
// MULTI_USER = 'multi_user',
// OAUTH_ONLY = 'oauth_only'
}
@ -54,8 +63,7 @@ export interface SingleUserAuth extends AuthMethod {
/**
* Union type for allowed authentication methods.
*/
export type ValidAuthMethod =
SingleUserAuth /* | MultiUserAuth | OAuthOnlyAuth */;
export type ValidAuthMethod = SingleUserAuth /* | MultiUserAuth | OAuthOnlyAuth */;
/**
* Configuration for OAuth authentication.