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', PARTICIPANT_TOKEN_COOKIE_NAME: 'OvMeetParticipantToken',
RECORDING_TOKEN_COOKIE_NAME: 'OvMeetRecordingToken', 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 // Token expiration times
ACCESS_TOKEN_EXPIRATION: '2h', ACCESS_TOKEN_EXPIRATION: '2h',
REFRESH_TOKEN_EXPIRATION: '1d', 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_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 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 // Authentication usernames
ANONYMOUS_USER: 'anonymous', ANONYMOUS_USER: 'anonymous',
API_USER: 'api-user', API_USER: 'api-user',

View File

@ -1,3 +1,4 @@
import { AuthTransportMode } from '@typings-ce';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ClaimGrants } from 'livekit-server-sdk'; import { ClaimGrants } from 'livekit-server-sdk';
import { container } from '../config/index.js'; import { container } from '../config/index.js';
@ -11,7 +12,7 @@ import {
rejectRequestFromMeetError rejectRequestFromMeetError
} from '../models/error.model.js'; } from '../models/error.model.js';
import { AuthService, LoggerService, TokenService, UserService } from '../services/index.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) => { export const login = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
@ -31,38 +32,60 @@ export const login = async (req: Request, res: Response) => {
const tokenService = container.get(TokenService); const tokenService = container.get(TokenService);
const accessToken = await tokenService.generateAccessToken(user); const accessToken = await tokenService.generateAccessToken(user);
const refreshToken = await tokenService.generateRefreshToken(user); const refreshToken = await tokenService.generateRefreshToken(user);
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
)
);
logger.info(`Login succeeded for user '${username}'`); logger.info(`Login succeeded for user '${username}'`);
return res.status(200).json({ message: `User '${username}' logged in successfully` }); 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` });
}
} catch (error) { } 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) => {
res.clearCookie(INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME); const transportMode = await getAuthTransportMode();
res.clearCookie(INTERNAL_CONFIG.REFRESH_TOKEN_COOKIE_NAME, {
path: `${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/auth` 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' }); return res.status(200).json({ message: 'Logout successful' });
}; };
export const refreshToken = async (req: Request, res: Response) => { export const refreshToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
logger.verbose('Refresh token request received'); 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) { if (!refreshToken) {
logger.warn('No refresh token provided'); logger.warn('No refresh token provided');
@ -93,13 +116,25 @@ export const refreshToken = async (req: Request, res: Response) => {
try { try {
const accessToken = await tokenService.generateAccessToken(user); const accessToken = await tokenService.generateAccessToken(user);
res.cookie(
INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME,
accessToken,
getCookieOptions('/', INTERNAL_CONFIG.ACCESS_TOKEN_EXPIRATION)
);
logger.info(`Access token refreshed for user '${username}'`); logger.info(`Access token refreshed for user '${username}'`);
return res.status(200).json({ message: `Access token for user '${username}' successfully refreshed` }); 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` });
}
} catch (error) { } catch (error) {
handleError(res, error, 'refreshing token'); handleError(res, error, 'refreshing token');
} }

View File

@ -13,11 +13,7 @@ export const updateSecurityConfig = async (req: Request, res: Response) => {
try { try {
const globalConfig = await storageService.getGlobalConfig(); const globalConfig = await storageService.getGlobalConfig();
const currentAuth = globalConfig.securityConfig.authentication; globalConfig.securityConfig.authentication = { ...securityConfig.authentication };
const newAuth = securityConfig.authentication;
currentAuth.authMethod = newAuth.authMethod;
currentAuth.authModeToAccessRoom = newAuth.authModeToAccessRoom;
await storageService.saveGlobalConfig(globalConfig); await storageService.saveGlobalConfig(globalConfig);
return res.status(200).json({ message: 'Security config updated successfully' }); 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 { Request, Response } from 'express';
import { container } from '../config/index.js'; import { container } from '../config/index.js';
import INTERNAL_CONFIG from '../config/internal-config.js'; import INTERNAL_CONFIG from '../config/internal-config.js';
@ -9,23 +9,25 @@ import {
rejectRequestFromMeetError rejectRequestFromMeetError
} from '../models/error.model.js'; } from '../models/error.model.js';
import { LoggerService, ParticipantService, RoomService, TokenService } from '../services/index.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) => { export const generateParticipantToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const participantService = container.get(ParticipantService); const participantService = container.get(ParticipantService);
const tokenService = container.get(TokenService);
const participantOptions: ParticipantOptions = req.body; const participantOptions: ParticipantOptions = req.body;
const { roomId } = participantOptions; 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]; const previousToken = req.cookies[INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME];
let currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] = []; let currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] = [];
if (previousToken) { if (previousToken) {
// If there is a previous token, extract the roles from it // 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 // 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'); logger.verbose('Previous participant token found. Extracting roles');
const tokenService = container.get(TokenService);
try { try {
const claims = tokenService.getClaimsIgnoringExpiration(previousToken); const claims = tokenService.getClaimsIgnoringExpiration(previousToken);
@ -38,9 +40,15 @@ export const generateParticipantToken = async (req: Request, res: Response) => {
try { try {
logger.verbose(`Generating participant token for room '${roomId}'`); logger.verbose(`Generating participant token for room '${roomId}'`);
const token = await participantService.generateOrRefreshParticipantToken(participantOptions, currentRoles); const token = await participantService.generateOrRefreshParticipantToken(participantOptions, currentRoles);
res.cookie(INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/'));
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 }); return res.status(200).json({ token });
} catch (error) { } catch (error) {
handleError(res, error, `generating participant token for room '${roomId}'`); 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) => { export const refreshParticipantToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const tokenService = container.get(TokenService);
const participantService = container.get(ParticipantService);
// Check if there is a previous token // Check if there is a previous token
const previousToken = req.cookies[INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME]; const previousToken = await getRecordingToken(req);
if (!previousToken) { if (!previousToken) {
logger.verbose('No previous participant token found. Cannot refresh.'); 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 // Extract roles from the previous token
const tokenService = container.get(TokenService);
const participantService = container.get(ParticipantService);
let currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] = []; let currentRoles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[] = [];
try { try {
@ -85,7 +93,13 @@ export const refreshParticipantToken = async (req: Request, res: Response) => {
true true
); );
res.cookie(INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/')); 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 }); return res.status(200).json({ token });
} catch (error) { } catch (error) {
handleError(res, error, `refreshing participant token for room '${roomId}'`); handleError(res, error, `refreshing participant token for room '${roomId}'`);

View File

@ -1,4 +1,5 @@
import { import {
AuthTransportMode,
MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings, MeetRoomDeletionPolicyWithRecordings,
MeetRoomDeletionSuccessCode, MeetRoomDeletionSuccessCode,
@ -12,7 +13,7 @@ import { container } from '../config/index.js';
import INTERNAL_CONFIG from '../config/internal-config.js'; import INTERNAL_CONFIG from '../config/internal-config.js';
import { handleError } from '../models/error.model.js'; import { handleError } from '../models/error.model.js';
import { LoggerService, ParticipantService, RoomService } from '../services/index.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) => { export const createRoom = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
@ -192,12 +193,17 @@ export const generateRecordingToken = async (req: Request, res: Response) => {
try { try {
const token = await roomService.generateRecordingToken(roomId, secret); 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)
);
}
res.cookie(
INTERNAL_CONFIG.RECORDING_TOKEN_COOKIE_NAME,
token,
getCookieOptions('/', INTERNAL_CONFIG.RECORDING_TOKEN_EXPIRATION)
);
return res.status(200).json({ token }); return res.status(200).json({ token });
} catch (error) { } catch (error) {
handleError(res, error, `generating recording token for room '${roomId}'`); handleError(res, error, `generating recording token for room '${roomId}'`);

View File

@ -26,6 +26,7 @@ import {
TokenService, TokenService,
UserService UserService
} from '../services/index.js'; } 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. * 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 // Configure token validatior for role-based access
export const tokenAndRoleValidator = (role: UserRole) => { export const tokenAndRoleValidator = (role: UserRole) => {
return async (req: Request) => { return async (req: Request) => {
const token = req.cookies[INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME]; const token = await getAccessToken(req);
if (!token) { if (!token) {
throw errorWithControl(errorUnauthorized(), false); throw errorWithControl(errorUnauthorized(), false);
@ -102,7 +103,8 @@ export const tokenAndRoleValidator = (role: UserRole) => {
// Configure token validator for participant access // Configure token validator for participant access
export const participantTokenValidator = async (req: Request) => { 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 // Check if the participant role is provided in the request headers
// This is required to distinguish roles when multiple are present in the token // 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 // Configure token validator for recording access
export const recordingTokenValidator = async (req: Request) => { 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 // Validate the recording token metadata
try { try {
@ -153,9 +156,7 @@ export const recordingTokenValidator = async (req: Request) => {
} }
}; };
const validateTokenAndSetSession = async (req: Request, cookieName: string) => { const validateTokenAndSetSession = async (req: Request, token: string | undefined) => {
const token = req.cookies[cookieName];
if (!token) { if (!token) {
throw errorWithControl(errorUnauthorized(), false); throw errorWithControl(errorUnauthorized(), false);
} }
@ -221,7 +222,7 @@ const getAuthenticatedUserOrAnonymous = async (req: Request): Promise<User> => {
let user: User | null = null; let user: User | null = null;
// Check if there is a user already authenticated // 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) { if (token) {
try { try {

View File

@ -1,6 +1,7 @@
import { import {
AuthenticationConfig, AuthenticationConfig,
AuthMode, AuthMode,
AuthTransportMode,
AuthType, AuthType,
SecurityConfig, SecurityConfig,
SingleUserAuth, SingleUserAuth,
@ -39,6 +40,8 @@ const WebhookTestSchema = z.object({
.regex(/^https?:\/\//, { message: 'URL must start with http:// or https://' }) .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 AuthModeSchema: z.ZodType<AuthMode> = z.enum([AuthMode.NONE, AuthMode.MODERATORS_ONLY, AuthMode.ALL_USERS]);
const AuthTypeSchema: z.ZodType<AuthType> = z.enum([AuthType.SINGLE_USER]); 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({ const AuthenticationConfigSchema: z.ZodType<AuthenticationConfig> = z.object({
authMethod: ValidAuthMethodSchema, authMethod: ValidAuthMethodSchema,
authTransportMode: AuthTransportModeSchema,
authModeToAccessRoom: AuthModeSchema 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 { inject, injectable } from 'inversify';
import ms from 'ms'; import ms from 'ms';
import { Readable } from 'stream'; import { Readable } from 'stream';
@ -722,7 +732,8 @@ export class MeetStorageService<
authMethod: { authMethod: {
type: AuthType.SINGLE_USER type: AuthType.SINGLE_USER
}, },
authModeToAccessRoom: AuthMode.NONE authModeToAccessRoom: AuthMode.NONE,
authTransportMode: AuthTransportMode.COOKIE
} }
}, },
roomsConfig: { roomsConfig: {

View File

@ -1,3 +1,4 @@
export * from './array.utils.js'; export * from './array.utils.js';
export * from './cookie.utils.js'; export * from './cookie.utils.js';
export * from './token.utils.js';
export * from './url.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 sessionStorageService = inject(SessionStorageService);
const { roomId, secret: querySecret, participantName, leaveRedirectUrl, showOnlyRecordings } = extractParams(route); 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 // Handle leave redirect URL logic
handleLeaveRedirectUrl(leaveRedirectUrl); handleLeaveRedirectUrl(leaveRedirectUrl);
@ -48,7 +48,7 @@ export const extractRecordingQueryParamsGuard: CanActivateFn = (route: Activated
const sessionStorageService = inject(SessionStorageService); const sessionStorageService = inject(SessionStorageService);
const { roomId, secret: querySecret } = extractParams(route); const { roomId, secret: querySecret } = extractParams(route);
const secret = querySecret || sessionStorageService.getRoomSecret(roomId); const secret = querySecret || sessionStorageService.getRoomSecret();
if (!secret) { if (!secret) {
// If no secret is provided, redirect to the error page // If no secret is provided, redirect to the error page
@ -83,9 +83,8 @@ const handleLeaveRedirectUrl = (leaveRedirectUrl: string | undefined) => {
return; 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 (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 parentUrl = document.referrer;
const parentOrigin = new URL(parentUrl).origin; const parentOrigin = new URL(parentUrl).origin;
navigationService.setLeaveRedirectUrl(parentOrigin + leaveRedirectUrl); navigationService.setLeaveRedirectUrl(parentOrigin + leaveRedirectUrl);

View File

@ -1,28 +1,68 @@
import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { Router } from '@angular/router'; 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'; 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) => { export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
const router: Router = inject(Router); const router: Router = inject(Router);
const authService: AuthService = inject(AuthService); const authService: AuthService = inject(AuthService);
const roomService = inject(RoomService); const roomService = inject(RoomService);
const participantTokenService = inject(ParticipantService); const participantTokenService = inject(ParticipantService);
const recordingService = inject(RecordingService); const recordingService = inject(RecordingService);
const tokenStorageService = inject(TokenStorageService);
const pageUrl = router.currentNavigation()?.finalUrl?.toString() || router.url; const pageUrl = router.currentNavigation()?.finalUrl?.toString() || router.url;
const requestUrl = req.url; const requestUrl = req.url;
// Clone request with credentials for cookie mode
req = req.clone({ req = req.clone({
withCredentials: true withCredentials: true
}); });
// Add all authorization headers if tokens exist
req = addAuthHeadersIfNeeded(req, tokenStorageService);
const refreshAccessToken = (firstError: HttpErrorResponse) => { const refreshAccessToken = (firstError: HttpErrorResponse) => {
console.log('Refreshing access token...'); console.log('Refreshing access token...');
return authService.refreshToken().pipe( return from(authService.refreshToken()).pipe(
switchMap(() => { switchMap(() => {
console.log('Access token refreshed'); console.log('Access token refreshed');
req = addAuthHeadersIfNeeded(req, tokenStorageService);
return next(req); return next(req);
}), }),
catchError(async (error: HttpErrorResponse) => { catchError(async (error: HttpErrorResponse) => {
@ -55,6 +95,7 @@ export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, ne
).pipe( ).pipe(
switchMap(() => { switchMap(() => {
console.log('Participant token refreshed'); console.log('Participant token refreshed');
req = addAuthHeadersIfNeeded(req, tokenStorageService);
return next(req); return next(req);
}), }),
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
@ -76,6 +117,7 @@ export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, ne
return from(recordingService.generateRecordingToken(roomId, secret)).pipe( return from(recordingService.generateRecordingToken(roomId, secret)).pipe(
switchMap(() => { switchMap(() => {
console.log('Recording token refreshed'); console.log('Recording token refreshed');
req = addAuthHeadersIfNeeded(req, tokenStorageService);
return next(req); return next(req);
}), }),
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {

View File

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

View File

@ -28,6 +28,7 @@ import {
RecordingService, RecordingService,
RoomService, RoomService,
SessionStorageService, SessionStorageService,
TokenStorageService,
WebComponentManagerService WebComponentManagerService
} from '@lib/services'; } from '@lib/services';
import { import {
@ -64,27 +65,27 @@ import {
import { combineLatest, Subject, takeUntil } from 'rxjs'; import { combineLatest, Subject, takeUntil } from 'rxjs';
@Component({ @Component({
selector: 'ov-meeting', selector: 'ov-meeting',
templateUrl: './meeting.component.html', templateUrl: './meeting.component.html',
styleUrls: ['./meeting.component.scss'], styleUrls: ['./meeting.component.scss'],
imports: [ imports: [
OpenViduComponentsUiModule, OpenViduComponentsUiModule,
// ApiDirectiveModule, // ApiDirectiveModule,
CommonModule, CommonModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
MatCardModule, MatCardModule,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
MatIconButton, MatIconButton,
MatMenuModule, MatMenuModule,
MatDividerModule, MatDividerModule,
MatTooltipModule, MatTooltipModule,
MatRippleModule, MatRippleModule,
ShareMeetingLinkComponent ShareMeetingLinkComponent
] ]
}) })
export class MeetingComponent implements OnInit { export class MeetingComponent implements OnInit {
participantForm = new FormGroup({ participantForm = new FormGroup({
@ -130,7 +131,8 @@ export class MeetingComponent implements OnInit {
protected clipboard: Clipboard, protected clipboard: Clipboard,
protected viewportService: ViewportService, protected viewportService: ViewportService,
protected ovThemeService: OpenViduThemeService, protected ovThemeService: OpenViduThemeService,
protected configService: GlobalConfigService protected configService: GlobalConfigService,
protected tokenStorageService: TokenStorageService
) { ) {
this.features = this.featureConfService.features; this.features = this.featureConfService.features;
@ -488,9 +490,12 @@ export class MeetingComponent implements OnInit {
}; };
this.wcManagerService.sendMessageToParent(message); 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) { 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 // Navigate to the disconnected page with the reason

View File

@ -14,19 +14,19 @@ import { formatDurationToTime } from '@lib/utils';
import { ViewportService } from 'openvidu-components-angular'; import { ViewportService } from 'openvidu-components-angular';
@Component({ @Component({
selector: 'ov-view-recording', selector: 'ov-view-recording',
templateUrl: './view-recording.component.html', templateUrl: './view-recording.component.html',
styleUrls: ['./view-recording.component.scss'], styleUrls: ['./view-recording.component.scss'],
imports: [ imports: [
MatCardModule, MatCardModule,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
DatePipe, DatePipe,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatTooltipModule, MatTooltipModule,
MatSnackBarModule, MatSnackBarModule,
RecordingVideoPlayerComponent RecordingVideoPlayerComponent
] ]
}) })
export class ViewRecordingComponent implements OnInit { export class ViewRecordingComponent implements OnInit {
recording?: MeetRecordingInfo; recording?: MeetRecordingInfo;
@ -138,6 +138,4 @@ export class ViewRecordingComponent implements OnInit {
this.router.navigate(['/recordings']); this.router.navigate(['/recordings']);
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,7 +46,7 @@ export class RoomService {
setRoomSecret(secret: string, updateStorage = true) { setRoomSecret(secret: string, updateStorage = true) {
this.roomSecret = secret; this.roomSecret = secret;
if (updateStorage) { 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. * Provides methods to store, retrieve, and remove data from sessionStorage.
*/ */
export class SessionStorageService { 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. * @param secret The secret to store.
*/ */
public setRoomSecret(roomId: string, secret: string): void { public setRoomSecret(secret: string): void {
this.set(`room_secret_${roomId}`, secret); 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. * @returns The stored secret or null if not found.
*/ */
public getRoomSecret(roomId: string): string | null { public getRoomSecret(): string | null {
return this.get<string>(`room_secret_${roomId}`) ?? null; return this.get<string>(this.ROOM_SECRET_KEY);
} }
/** /**
* Removes the room secret for a specific room. * Removes the room secret.
*
* @param roomId The room ID.
*/ */
public removeRoomSecret(roomId: string): void { public removeRoomSecret(): void {
this.remove(`room_secret_${roomId}`); this.remove(this.ROOM_SECRET_KEY);
} }
/** /**
@ -45,7 +42,7 @@ export class SessionStorageService {
* @param redirectUrl The URL to redirect to. * @param redirectUrl The URL to redirect to.
*/ */
public setRedirectUrl(redirectUrl: string): void { 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. * @returns The redirect URL or null if not found.
*/ */
public getRedirectUrl(): string | null { 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,38 +1,47 @@
export interface AuthenticationConfig { export interface AuthenticationConfig {
authMethod: ValidAuthMethod; authMethod: ValidAuthMethod;
authModeToAccessRoom: AuthMode; 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. * Authentication modes available to enter a room.
*/ */
export const enum AuthMode { export const enum AuthMode {
NONE = 'none', // No authentication required NONE = 'none', // No authentication required
MODERATORS_ONLY = 'moderators_only', // Only moderators need authentication MODERATORS_ONLY = 'moderators_only', // Only moderators need authentication
ALL_USERS = 'all_users', // All users need authentication ALL_USERS = 'all_users' // All users need authentication
} }
/** /**
* Authentication method base interface. * Authentication method base interface.
*/ */
export interface AuthMethod { export interface AuthMethod {
type: AuthType; type: AuthType;
} }
/** /**
* Enum for authentication types. * Enum for authentication types.
*/ */
export const enum AuthType { export const enum AuthType {
SINGLE_USER = 'single_user', SINGLE_USER = 'single_user'
// MULTI_USER = 'multi_user', // MULTI_USER = 'multi_user',
// OAUTH_ONLY = 'oauth_only' // OAUTH_ONLY = 'oauth_only'
} }
/** /**
* Authentication method: Single user with fixed credentials. * Authentication method: Single user with fixed credentials.
*/ */
export interface SingleUserAuth extends AuthMethod { export interface SingleUserAuth extends AuthMethod {
type: AuthType.SINGLE_USER; type: AuthType.SINGLE_USER;
} }
/** /**
@ -54,8 +63,7 @@ export interface SingleUserAuth extends AuthMethod {
/** /**
* Union type for allowed authentication methods. * Union type for allowed authentication methods.
*/ */
export type ValidAuthMethod = export type ValidAuthMethod = SingleUserAuth /* | MultiUserAuth | OAuthOnlyAuth */;
SingleUserAuth /* | MultiUserAuth | OAuthOnlyAuth */;
/** /**
* Configuration for OAuth authentication. * Configuration for OAuth authentication.