diff --git a/react/features/base/conference/middleware.any.js b/react/features/base/conference/middleware.any.js index d77325ac5..692fb39c4 100644 --- a/react/features/base/conference/middleware.any.js +++ b/react/features/base/conference/middleware.any.js @@ -11,6 +11,7 @@ import { reloadNow } from '../../app/actions'; import { openDisplayNamePrompt } from '../../display-name'; import { showErrorNotification } from '../../notifications'; import { CONNECTION_ESTABLISHED, CONNECTION_FAILED, connectionDisconnected } from '../connection'; +import { validateJwt } from '../jwt'; import { JitsiConferenceErrors } from '../lib-jitsi-meet'; import { MEDIA_TYPE } from '../media'; import { @@ -247,6 +248,26 @@ function _connectionEstablished({ dispatch }, next, action) { return result; } +/** + * Logs jwt validation errors from xmpp and from the client-side validator. + * + * @param {string} message -The error message from xmpp. + * @param {Object} state - The redux state. + * @returns {void} + */ +function _logJwtErrors(message, state) { + const { jwt } = state['features/base/jwt']; + + if (!jwt) { + return; + } + + const errorKeys = validateJwt(jwt); + + message && logger.error(`JWT error: ${message}`); + errorKeys.length && logger.error('JWT parsing error:', errorKeys); +} + /** * Notifies the feature base/conference that the action * {@code CONNECTION_FAILED} is being dispatched within a specific redux @@ -262,6 +283,8 @@ function _connectionEstablished({ dispatch }, next, action) { * @returns {Object} The value returned by {@code next(action)}. */ function _connectionFailed({ dispatch, getState }, next, action) { + _logJwtErrors(action.error.message, getState()); + const result = next(action); if (typeof beforeUnloadHandler !== 'undefined') { diff --git a/react/features/base/jwt/constants.js b/react/features/base/jwt/constants.js new file mode 100644 index 000000000..d95eba49b --- /dev/null +++ b/react/features/base/jwt/constants.js @@ -0,0 +1,15 @@ +/** + * The list of supported meeting features to enable/disable through jwt. + */ +export const MEET_FEATURES = [ + 'branding', + 'calendar', + 'callstats', + 'livestreaming', + 'lobby', + 'moderation', + 'outbound-call', + 'recording', + 'room', + 'transcription' +]; diff --git a/react/features/base/jwt/functions.js b/react/features/base/jwt/functions.js index 1d398c85a..84a296e90 100644 --- a/react/features/base/jwt/functions.js +++ b/react/features/base/jwt/functions.js @@ -1,7 +1,11 @@ /* @flow */ +import jwtDecode from 'jwt-decode'; + import { parseURLParams } from '../util'; +import { MEET_FEATURES } from './constants'; + /** * Retrieves the JSON Web Token (JWT), if any, defined by a specific * {@link URL}. @@ -26,3 +30,108 @@ export function getJwtName(state: Object) { return user?.name; } + +/** + * Checks whether a given timestamp is a valid UNIX timestamp in seconds. + * We convert to miliseconds during the check since `Date` works with miliseconds 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: any) { + 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 = []; + + if (!jwt) { + return errors; + } + + const currentTimestamp = new Date().getTime(); + + try { + const header = jwtDecode(jwt, { header: true }); + const payload = jwtDecode(jwt); + + if (!header || !payload) { + errors.push('- Missing header or payload'); + + return errors; + } + + const { kid } = header; + + // if Key ID is missing, we return the error immediately without further validations. + if (!kid) { + errors.push('- Key ID(kid) missing'); + + return errors; + } + + // JaaS only + if (kid.startsWith('vpaas-magic-cookie')) { + if (kid.substring(0, header.kid.indexOf('/')) !== payload.sub) { + errors.push('- Key ID(kid) does not match sub'); + } + if (payload.aud !== 'jitsi') { + errors.push('- invalid `aud` value. It should be `jitsi`'); + } + + if (payload.iss !== 'chat') { + errors.push('- invalid `iss` value. It should be `chat`'); + } + + if (!payload.context?.features) { + errors.push('- `features` object is missing from the payload'); + } + } + + if (!isValidUnixTimestamp(payload.nbf)) { + errors.push('- invalid `nbf` value'); + } else if (currentTimestamp < payload.nbf * 1000) { + errors.push('- `nbf` value is in the future'); + } + + if (!isValidUnixTimestamp(payload.exp)) { + errors.push('- invalid `exp` value'); + } else if (currentTimestamp > payload.exp * 1000) { + errors.push('- token is expired'); + } + + if (!payload.context) { + errors.push('- `context` object is missing from the payload'); + } else if (payload.context.features) { + const { features } = payload.context; + + Object.keys(features).forEach(feature => { + if (MEET_FEATURES.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(`- Invalid value for feature: ${feature}`); + } + } else { + errors.push(`- Invalid feature: ${feature}`); + } + }); + } + } catch (e) { + errors.push(e ? e.message : '- unspecified jwt error'); + } + + return errors; +} diff --git a/react/features/base/jwt/index.js b/react/features/base/jwt/index.js index 08fe9014b..3bce1fb33 100644 --- a/react/features/base/jwt/index.js +++ b/react/features/base/jwt/index.js @@ -1,3 +1,4 @@ export * from './actions'; export * from './actionTypes'; export * from './functions'; +export * from './constants';