init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
/**
* The type of redux action which set an avatar URL for delayed loading.
*
* {
* type: SET_DELAYED_LOAD_OF_AVATAR_URL
* avatarUrl: string
* }
*/
export const SET_DELAYED_LOAD_OF_AVATAR_URL = 'SET_DELAYED_LOAD_OF_AVATAR_URL';
/**
* The type of redux action which stores a specific JSON Web Token (JWT) into
* the redux store.
*
* {
* type: SET_JWT,
* jwt: string
* }
*/
export const SET_JWT = 'SET_JWT';
/**
* The type of redux action which sets a known avatar URL.
*
* {
* type: SET_KNOWN_AVATAR_URL,
* avatarUrl: string
* }
*/
export const SET_KNOWN_AVATAR_URL = 'SET_KNOWN_AVATAR_URL';

View File

@@ -0,0 +1,49 @@
import { SET_DELAYED_LOAD_OF_AVATAR_URL, SET_JWT, SET_KNOWN_AVATAR_URL } from './actionTypes';
/**
* Sets an avatar URL for delayed loading.
*
* @param {string} avatarUrl - The avatar URL to set for delayed loading.
* @returns {{
* type: SET_DELAYED_LOAD_OF_AVATAR_URL,
* avatarUrl: string
* }}
*/
export function setDelayedLoadOfAvatarUrl(avatarUrl?: string) {
return {
type: SET_DELAYED_LOAD_OF_AVATAR_URL,
avatarUrl
};
}
/**
* Stores a specific JSON Web Token (JWT) into the redux store.
*
* @param {string} [jwt] - The JSON Web Token (JWT) to store.
* @returns {{
* type: SET_JWT,
* jwt: (string|undefined)
* }}
*/
export function setJWT(jwt?: string) {
return {
type: SET_JWT,
jwt
};
}
/**
* Sets a known avatar URL.
*
* @param {string} avatarUrl - The avatar URL to set as known.
* @returns {{
* type: SET_KNOWN_AVATAR_URL,
* avatarUrl: string
* }}
*/
export function setKnownAvatarUrl(avatarUrl: string) {
return {
type: SET_KNOWN_AVATAR_URL,
avatarUrl
};
}

View File

@@ -0,0 +1,45 @@
import { ParticipantFeaturesKey } from '../participants/types';
/**
* The list of supported meeting features to enable/disable through jwt.
*/
export const MEET_FEATURES: Record<string, ParticipantFeaturesKey> = {
BRANDING: 'branding',
CALENDAR: 'calendar',
CREATE_POLLS: 'create-polls',
FILE_UPLOAD: 'file-upload',
FLIP: 'flip',
INBOUND_CALL: 'inbound-call',
LIVESTREAMING: 'livestreaming',
LOBBY: 'lobby',
MODERATION: 'moderation',
OUTBOUND_CALL: 'outbound-call',
RECORDING: 'recording',
ROOM: 'room',
SCREEN_SHARING: 'screen-sharing',
SEND_GROUPCHAT: 'send-groupchat',
LIST_VISITORS: 'list-visitors',
SIP_INBOUND_CALL: 'sip-inbound-call',
SIP_OUTBOUND_CALL: 'sip-outbound-call',
TRANSCRIPTION: 'transcription'
};
/**
* The JWT validation errors for JaaS.
*/
export const JWT_VALIDATION_ERRORS = {
AUD_INVALID: 'audInvalid',
CONTEXT_NOT_FOUND: 'contextNotFound',
EXP_INVALID: 'expInvalid',
FEATURE_INVALID: 'featureInvalid',
FEATURE_VALUE_INVALID: 'featureValueInvalid',
FEATURES_NOT_FOUND: 'featuresNotFound',
HEADER_NOT_FOUND: 'headerNotFound',
ISS_INVALID: 'issInvalid',
KID_NOT_FOUND: 'kidNotFound',
KID_MISMATCH: 'kidMismatch',
NBF_FUTURE: 'nbfFuture',
NBF_INVALID: 'nbfInvalid',
PAYLOAD_NOT_FOUND: 'payloadNotFound',
TOKEN_EXPIRED: 'tokenExpired'
};

View File

@@ -0,0 +1,241 @@
// @ts-expect-error
import jwtDecode from 'jwt-decode';
import { IReduxState } from '../../app/types';
import { isVpaasMeeting } from '../../jaas/functions';
import { getLocalParticipant } from '../participants/functions';
import { IParticipantFeatures, ParticipantFeaturesKey } from '../participants/types';
import { parseURLParams } from '../util/parseURLParams';
import { JWT_VALIDATION_ERRORS, MEET_FEATURES } from './constants';
import logger from './logger';
/**
* Retrieves the JSON Web Token (JWT), if any, defined by a specific
* {@link URL}.
*
* @param {URL} url - The {@code URL} to parse and retrieve the JSON Web Token
* (JWT), if any, from.
* @returns {string} The JSON Web Token (JWT), if any, defined by the specified
* {@code url}; otherwise, {@code undefined}.
*/
export function parseJWTFromURLParams(url: URL | typeof window.location = window.location) {
// @ts-ignore
const jwt = parseURLParams(url, false, 'hash').jwt;
// TODO: eventually remove the search param and only pull from the hash
// @ts-ignore
return jwt ? jwt : parseURLParams(url, true, 'search').jwt;
}
/**
* Returns the user name after decoding the jwt.
*
* @param {IReduxState} state - The app state.
* @returns {string}
*/
export function getJwtName(state: IReduxState) {
const { user } = state['features/base/jwt'];
return user?.name;
}
/**
* Check if the given JWT feature is enabled.
*
* @param {IReduxState} state - The app state.
* @param {string} feature - The feature we want to check.
* @param {boolean} ifNotInFeatures - Default value if features prop exists but does not have the {@code feature}.
* @returns {boolean}
*/
export function isJwtFeatureEnabled(
state: IReduxState,
feature: ParticipantFeaturesKey,
ifNotInFeatures: boolean
) {
let { features } = getLocalParticipant(state) || {};
if (typeof features === 'undefined' && isVpaasMeeting(state)) {
// for vpaas the backend is always initialized with empty features if those are missing
features = {};
}
return isJwtFeatureEnabledStateless({
localParticipantFeatures: features,
feature,
ifNotInFeatures
});
}
interface IIsJwtFeatureEnabledStatelessParams {
feature: ParticipantFeaturesKey;
ifNotInFeatures: boolean;
jwt?: string;
localParticipantFeatures?: IParticipantFeatures;
}
/**
* Check if the given JWT feature is enabled.
*
* @param {ILocalParticipant} localParticipantFeatures - The features of the local participant.
* @param {string} feature - The feature we want to check.
* @param {boolean} ifNotInFeatures - Default value if features is missing
* or prop exists but does not have the {@code feature}.
* @returns {boolean}
*/
export function isJwtFeatureEnabledStateless({
localParticipantFeatures: features,
feature,
ifNotInFeatures
}: IIsJwtFeatureEnabledStatelessParams) {
if (typeof features?.[feature] === 'undefined') {
return ifNotInFeatures;
}
return String(features[feature]) === 'true';
}
/**
* Checks whether a given timestamp is a valid UNIX timestamp in seconds.
* We convert to milliseconds during the check since `Date` works with milliseconds for UNIX timestamp values.
*
* @param {any} timestamp - A UNIX timestamp in seconds as stored in the jwt.
* @returns {boolean} - Whether the timestamp is indeed a valid UNIX timestamp or not.
*/
function isValidUnixTimestamp(timestamp: number | string) {
return typeof timestamp === 'number' && timestamp * 1000 === new Date(timestamp * 1000).getTime();
}
/**
* Returns a list with all validation errors for the given jwt.
*
* @param {string} jwt - The jwt.
* @returns {Array} - An array containing all jwt validation errors.
*/
export function validateJwt(jwt: string) {
const errors: Object[] = [];
const currentTimestamp = new Date().getTime();
try {
const header = jwtDecode(jwt, { header: true });
const payload = jwtDecode(jwt);
if (!header) {
errors.push({ key: JWT_VALIDATION_ERRORS.HEADER_NOT_FOUND });
return errors;
}
if (!payload) {
errors.push({ key: JWT_VALIDATION_ERRORS.PAYLOAD_NOT_FOUND });
return errors;
}
const {
aud,
context,
exp,
iss,
nbf,
sub
} = payload;
// JaaS only
if (sub?.startsWith('vpaas-magic-cookie')) {
const { kid } = header;
// if Key ID is missing, we return the error immediately without further validations.
if (!kid) {
errors.push({ key: JWT_VALIDATION_ERRORS.KID_NOT_FOUND });
return errors;
}
if (kid.substring(0, kid.indexOf('/')) !== sub) {
errors.push({ key: JWT_VALIDATION_ERRORS.KID_MISMATCH });
}
if (aud !== 'jitsi') {
errors.push({ key: JWT_VALIDATION_ERRORS.AUD_INVALID });
}
if (iss !== 'chat') {
errors.push({ key: JWT_VALIDATION_ERRORS.ISS_INVALID });
}
if (!context?.features) {
errors.push({ key: JWT_VALIDATION_ERRORS.FEATURES_NOT_FOUND });
}
}
if (nbf) { // nbf value is optional
if (!isValidUnixTimestamp(nbf)) {
errors.push({ key: JWT_VALIDATION_ERRORS.NBF_INVALID });
} else if (currentTimestamp < nbf * 1000) {
errors.push({ key: JWT_VALIDATION_ERRORS.NBF_FUTURE });
}
}
if (!isValidUnixTimestamp(exp)) {
errors.push({ key: JWT_VALIDATION_ERRORS.EXP_INVALID });
} else if (currentTimestamp > exp * 1000) {
errors.push({ key: JWT_VALIDATION_ERRORS.TOKEN_EXPIRED });
}
if (!context) {
errors.push({ key: JWT_VALIDATION_ERRORS.CONTEXT_NOT_FOUND });
} else if (context.features) {
const { features } = context;
const meetFeatures = Object.values(MEET_FEATURES);
(Object.keys(features) as ParticipantFeaturesKey[]).forEach(feature => {
if (meetFeatures.includes(feature)) {
const featureValue = features[feature];
// cannot use truthy or falsy because we need the exact value and type check.
if (
featureValue !== true
&& featureValue !== false
&& featureValue !== 'true'
&& featureValue !== 'false'
) {
errors.push({
key: JWT_VALIDATION_ERRORS.FEATURE_VALUE_INVALID,
args: { feature }
});
}
} else {
errors.push({
key: JWT_VALIDATION_ERRORS.FEATURE_INVALID,
args: { feature }
});
}
});
}
} catch (e: any) {
logger.error(`Unspecified JWT error${e?.message ? `: ${e.message}` : ''}`);
}
return errors;
}
/**
* Extracts and returns the expiration date of jwt.
*
* @param {string|undefined} jwt - The jwt to check.
* @returns {Date} The expiration date of the jwt.
*/
export function getJwtExpirationDate(jwt: string | undefined) {
if (!jwt) {
return;
}
const payload = jwtDecode(jwt);
if (payload) {
const { exp } = payload;
return new Date(exp * 1000);
}
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../logging/functions';
export default getLogger('features/base/jwt');

View File

@@ -0,0 +1,323 @@
// @ts-expect-error
import jwtDecode from 'jwt-decode';
import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import { isVpaasMeeting } from '../../jaas/functions';
import { getCurrentConference } from '../conference/functions';
import { SET_CONFIG } from '../config/actionTypes';
import { CONNECTION_ESTABLISHED, SET_LOCATION_URL } from '../connection/actionTypes';
import { participantUpdated } from '../participants/actions';
import { getLocalParticipant } from '../participants/functions';
import { IParticipant } from '../participants/types';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { parseURIString } from '../util/uri';
import { SET_JWT } from './actionTypes';
import { setDelayedLoadOfAvatarUrl, setJWT, setKnownAvatarUrl } from './actions';
import { parseJWTFromURLParams } from './functions';
import logger from './logger';
/**
* Set up a state change listener to perform maintenance tasks when the conference
* is left or failed, e.g. Clear any delayed load avatar url.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch }, previousConference): void => {
if (conference !== previousConference) {
dispatch(setDelayedLoadOfAvatarUrl());
}
});
/**
* Middleware to parse token data upon setting a new room URL.
*
* @param {Store} store - The redux store.
* @private
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case SET_CONFIG:
case SET_LOCATION_URL:
// XXX The JSON Web Token (JWT) is not the only piece of state that we
// have decided to store in the feature jwt
return _setConfigOrLocationURL(store, next, action);
case CONNECTION_ESTABLISHED: {
const state = store.getState();
const delayedLoadOfAvatarUrl = state['features/base/jwt'].delayedLoadOfAvatarUrl;
if (delayedLoadOfAvatarUrl) {
_overwriteLocalParticipant(store, {
avatarURL: delayedLoadOfAvatarUrl
});
store.dispatch(setDelayedLoadOfAvatarUrl());
store.dispatch(setKnownAvatarUrl(delayedLoadOfAvatarUrl));
}
}
case SET_JWT:
return _setJWT(store, next, action);
}
return next(action);
});
/**
* Overwrites the properties {@code avatarURL}, {@code email}, and {@code name}
* of the local participant stored in the redux state base/participants.
*
* @param {Store} store - The redux store.
* @param {Object} localParticipant - The {@code Participant} structure to
* overwrite the local participant stored in the redux store base/participants
* with.
* @private
* @returns {void}
*/
function _overwriteLocalParticipant(
{ dispatch, getState }: IStore,
{ avatarURL, email, id: jwtId, name, features }:
{ avatarURL?: string; email?: string; features?: any; id?: string; name?: string; }) {
let localParticipant;
if ((avatarURL || email || name || features) && (localParticipant = getLocalParticipant(getState))) {
const newProperties: IParticipant = {
id: localParticipant.id,
local: true
};
if (avatarURL) {
newProperties.avatarURL = avatarURL;
}
if (email) {
newProperties.email = email;
}
if (jwtId) {
newProperties.jwtId = jwtId;
}
if (name) {
newProperties.name = name;
}
if (features) {
newProperties.features = features;
}
dispatch(participantUpdated(newProperties));
}
}
/**
* Notifies the feature jwt that the action {@link SET_CONFIG} or
* {@link SET_LOCATION_URL} is being dispatched within a specific redux
* {@code store}.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Dispatch} next - The redux dispatch function to dispatch the
* specified {@code action} to the specified {@code store}.
* @param {Action} action - The redux action {@code SET_CONFIG} or
* {@code SET_LOCATION_URL} which is being dispatched in the specified
* {@code store}.
* @private
* @returns {Object} The new state that is the result of the reduction of the
* specified {@code action}.
*/
function _setConfigOrLocationURL({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const result = next(action);
const { locationURL } = getState()['features/base/connection'];
dispatch(
setJWT(locationURL ? parseJWTFromURLParams(locationURL) : undefined));
return result;
}
/**
* Notifies the feature jwt that the action {@link SET_JWT} is being dispatched
* within a specific redux {@code store}.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Dispatch} next - The redux dispatch function to dispatch the
* specified {@code action} to the specified {@code store}.
* @param {Action} action - The redux action {@code SET_JWT} which is being
* dispatched in the specified {@code store}.
* @private
* @returns {Object} The new state that is the result of the reduction of the
* specified {@code action}.
*/
function _setJWT(store: IStore, next: Function, action: AnyAction) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { jwt, type, ...actionPayload } = action;
if (!Object.keys(actionPayload).length) {
const state = store.getState();
if (jwt) {
let jwtPayload;
try {
jwtPayload = jwtDecode(jwt);
} catch (e) {
logger.error(e);
}
if (jwtPayload) {
const { context, iss, sub } = jwtPayload;
const { tokenGetUserInfoOutOfContext, tokenRespectTenant } = state['features/base/config'];
action.jwt = jwt;
action.issuer = iss;
if (context) {
const user = _user2participant(context.user || {});
action.callee = context.callee;
action.group = context.group;
action.server = context.server;
action.tenant = context.tenant || sub || undefined;
action.user = user;
const newUser = user ? { ...user } : {};
let features = context.features;
// eslint-disable-next-line max-depth
if (!isVpaasMeeting(state) && tokenRespectTenant && context.tenant) {
// we skip checking vpaas meetings as there are other backend rules in place
// this way vpaas users can still use this field if needed
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
const { tenant = '' } = parseURIString(locationURL.href) || {};
features = context.tenant === tenant || tenant === '' ? features : {};
}
if (newUser.avatarURL) {
const { knownAvatarUrl } = state['features/base/jwt'];
if (knownAvatarUrl !== newUser.avatarURL) {
store.dispatch(setDelayedLoadOfAvatarUrl(newUser.avatarURL));
newUser.avatarURL = undefined;
}
}
_overwriteLocalParticipant(
store, { ...newUser,
features });
// eslint-disable-next-line max-depth
if (context.user && context.user.role === 'visitor') {
action.preferVisitor = true;
}
} else if (tokenGetUserInfoOutOfContext
&& (jwtPayload.name || jwtPayload.picture || jwtPayload.email)) {
// there are some tokens (firebase) having picture and name on the main level.
_overwriteLocalParticipant(store, {
avatarURL: jwtPayload.picture,
name: jwtPayload.name,
email: jwtPayload.email
});
}
}
} else if (typeof APP === 'undefined') {
// The logic of restoring JWT overrides make sense only on mobile.
// On Web it should eventually be restored from storage, but there's
// no such use case yet.
const { user } = state['features/base/jwt'];
user && _undoOverwriteLocalParticipant(store, user);
}
}
return next(action);
}
/**
* Undoes/resets the values overwritten by {@link _overwriteLocalParticipant}
* by either clearing them or setting to default values. Only the values that
* have not changed since the overwrite happened will be restored.
*
* NOTE Once it is possible to edit and save participant properties, this
* function should restore values from the storage instead.
*
* @param {Store} store - The redux store.
* @param {Object} localParticipant - The {@code Participant} structure used
* previously to {@link _overwriteLocalParticipant}.
* @private
* @returns {void}
*/
function _undoOverwriteLocalParticipant(
{ dispatch, getState }: IStore,
{ avatarURL, name, email }: { avatarURL?: string; email?: string; name?: string; }) {
let localParticipant;
if ((avatarURL || name || email)
&& (localParticipant = getLocalParticipant(getState))) {
const newProperties: IParticipant = {
id: localParticipant.id,
local: true
};
if (avatarURL === localParticipant.avatarURL) {
newProperties.avatarURL = undefined;
}
if (email === localParticipant.email) {
newProperties.email = undefined;
}
if (name === localParticipant.name) {
newProperties.name = undefined;
}
newProperties.features = undefined;
dispatch(participantUpdated(newProperties));
}
}
/**
* Converts the JWT {@code context.user} structure to the {@code Participant}
* structure stored in the redux state base/participants.
*
* @param {Object} user - The JWT {@code context.user} structure to convert.
* @private
* @returns {{
* avatarURL: ?string,
* email: ?string,
* id: ?string,
* name: ?string,
* hidden-from-recorder: ?boolean
* }}
*/
function _user2participant({ avatar, avatarUrl, email, id, name, 'hidden-from-recorder': hiddenFromRecorder }:
{ avatar?: string; avatarUrl?: string; email: string; 'hidden-from-recorder': string | boolean;
id: string; name: string; }) {
const participant: {
avatarURL?: string;
email?: string;
hiddenFromRecorder?: boolean;
id?: string;
name?: string;
} = {};
if (typeof avatarUrl === 'string') {
participant.avatarURL = avatarUrl.trim();
} else if (typeof avatar === 'string') {
participant.avatarURL = avatar.trim();
}
if (typeof email === 'string') {
participant.email = email.trim();
}
if (typeof id === 'string') {
participant.id = id.trim();
}
if (typeof name === 'string') {
participant.name = name.trim();
}
if (hiddenFromRecorder === 'true' || hiddenFromRecorder === true) {
participant.hiddenFromRecorder = true;
}
return Object.keys(participant).length ? participant : undefined;
}

View File

@@ -0,0 +1,73 @@
import PersistenceRegistry from '../redux/PersistenceRegistry';
import ReducerRegistry from '../redux/ReducerRegistry';
import { equals } from '../redux/functions';
import { SET_DELAYED_LOAD_OF_AVATAR_URL, SET_JWT, SET_KNOWN_AVATAR_URL } from './actionTypes';
import logger from './logger';
export interface IJwtState {
callee?: {
name: string;
};
delayedLoadOfAvatarUrl?: string;
group?: string;
jwt?: string;
knownAvatarUrl?: string;
server?: string;
tenant?: string;
user?: {
id: string;
name: string;
};
}
PersistenceRegistry.register('features/base/jwt', {
knownAvatarUrl: true
});
/**
* Reduces redux actions which affect the JSON Web Token (JWT) stored in the
* redux store.
*
* @param {Object} state - The current redux state.
* @param {Object} action - The redux action to reduce.
* @returns {Object} The next redux state which is the result of reducing the
* specified {@code action}.
*/
ReducerRegistry.register<IJwtState>(
'features/base/jwt',
(state = {}, action): IJwtState => {
switch (action.type) {
case SET_DELAYED_LOAD_OF_AVATAR_URL: {
const nextState = {
...state,
delayedLoadOfAvatarUrl: action.avatarUrl
};
if (equals(state, nextState)) {
return state;
}
logger.info('JWT avatarURL temporarily not loaded till jwt is verified on connect');
return nextState;
}
case SET_JWT: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { type, ...payload } = action;
const nextState = {
...state,
...payload
};
return equals(state, nextState) ? state : nextState;
}
case SET_KNOWN_AVATAR_URL:
return {
...state,
knownAvatarUrl: action.avatarUrl
};
}
return state;
});