feat(authentication): Inline authentication for web

* fix(meeting_id): Depends on jitsi_session(uses session.user_region).

* feat(authentication): A static page that can be used with some auth providers like keycloak.

* fix(authentication): Implements inline authentication.

squash: Adds refresh token use when refresh token is needed on connection resuming.
squash: Fix bugs and move to PKCE flow.

* squash: Adds nonce verification.

* squash: Drops the closing logic.

* squash: Replace resuming event with CONNECTION_TOKEN_EXPIRED one.

* squash: Fixes comments.

* squash: Make sure we use tokenAuthUrl only when it is set and is not jaas.

* squash: Move CONNECTION_TOKEN_EXPIRED to web only middleware as it uses web only logic for now.

* squash: Fix comments.
pull/16990/head jitsi-meet_10797
Дамян Минков 4 months ago committed by GitHub
parent c48834a116
commit 96d02e8484
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      config.js
  2. 2
      lang/main.json
  3. 6
      react/features/app/actions.any.ts
  4. 3
      react/features/app/getRouteToRender.native.ts
  5. 39
      react/features/app/getRouteToRender.web.ts
  6. 1
      react/features/app/middlewares.web.ts
  7. 10
      react/features/authentication/actions.native.ts
  8. 181
      react/features/authentication/actions.web.ts
  9. 26
      react/features/authentication/functions.any.ts
  10. 8
      react/features/authentication/functions.native.ts
  11. 24
      react/features/authentication/functions.web.ts
  12. 16
      react/features/authentication/middleware.ts
  13. 2
      react/features/base/conference/actions.any.ts
  14. 11
      react/features/base/conference/middleware.any.ts
  15. 1
      react/features/base/config/configType.ts
  16. 10
      react/features/base/connection/actionTypes.ts
  17. 31
      react/features/base/connection/actions.any.ts
  18. 1
      react/features/base/connection/reducer.ts
  19. 12
      react/features/base/jwt/actions.ts
  20. 43
      react/features/base/jwt/middleware.ts
  21. 100
      react/features/base/jwt/middleware.web.ts
  22. 2
      react/features/base/jwt/reducer.ts
  23. 82
      react/features/prejoin/components/web/Prejoin.tsx
  24. 5
      react/features/settings/actions.native.ts
  25. 27
      react/features/settings/actions.web.ts
  26. 7
      react/features/settings/components/web/SettingsDialog.tsx
  27. 22
      resources/prosody-plugins/README.md
  28. 9
      resources/prosody-plugins/mod_auth_jitsi-anonymous.lua
  29. 169
      resources/prosody-plugins/mod_auth_token.lua
  30. 22
      resources/prosody-plugins/mod_jitsi_session.lua
  31. 1
      resources/prosody-plugins/mod_muc_meeting_id.lua
  32. 5
      resources/prosody-plugins/mod_muc_wait_for_host.lua
  33. 43
      static/logout.html
  34. 364
      static/sso.html

@ -1600,6 +1600,8 @@ var config = {
// An option to get for user info (name, picture, email) in the token outside the user context.
// Can be used with Firebase tokens.
// tokenGetUserInfoOutOfContext: false,
// An option to pass the token in the iframe API directly instead of using the redirect flow.
// tokenAuthInline: false,
// You can put an array of values to target different entity types in the invite dialog.
// Valid values are "phone", "room", "sip", "user", "videosipgw" and "email"

@ -383,6 +383,8 @@
"lockRoom": "Add meeting $t(lockRoomPassword)",
"lockTitle": "Lock failed",
"login": "Login",
"loginFailed": "Login failed.",
"loginOnResume": "Your authentication session has expired. You need to login again to continue the meeting.",
"loginQuestion": "Are you sure you want to login and leave the conference?",
"logoutQuestion": "Are you sure you want to logout and leave the conference?",
"logoutTitle": "Logout",

@ -113,12 +113,13 @@ export function maybeRedirectToTokenAuthUrl(
const audioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
const videoMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
if (!isTokenAuthEnabled(config)) {
if (!isTokenAuthEnabled(state)) {
return false;
}
// if tokenAuthUrl check jwt if is about to expire go through the url to get new token
const jwt = state['features/base/jwt'].jwt;
const refreshToken = state['features/base/jwt'].refreshToken;
const expirationDate = getJwtExpirationDate(jwt);
// if there is jwt and its expiration time is less than 3 minutes away
@ -137,7 +138,8 @@ export function maybeRedirectToTokenAuthUrl(
videoMuted
},
room,
tenant
tenant,
refreshToken
)
.then((tokenAuthServiceUrl: string | undefined) => {
if (!tokenAuthServiceUrl) {

@ -11,8 +11,9 @@ const route = {
* store.
*
* @param {any} _stateful - Used on web.
* @param {any} _dispatch - Used on web.
* @returns {Promise<Object>}
*/
export function _getRouteToRender(_stateful?: any) {
export function _getRouteToRender(_stateful?: any): Promise<object> {
return Promise.resolve(route);
}

@ -1,13 +1,10 @@
// @ts-expect-error
import { generateRoomWithoutSeparator } from '@jitsi/js-utils/random';
import { getTokenAuthUrl } from '../authentication/functions.web';
import { IStateful } from '../base/app/types';
import { isRoomValid } from '../base/conference/functions';
import { isSupportedBrowser } from '../base/environment/environment';
import { browser } from '../base/lib-jitsi-meet';
import { toState } from '../base/redux/functions';
import { parseURIString } from '../base/util/uri';
import Conference from '../conference/components/web/Conference';
import { getDeepLinkingPage } from '../deep-linking/functions';
import UnsupportedDesktopBrowser from '../unsupported-browser/components/UnsupportedDesktopBrowser';
@ -23,9 +20,10 @@ import { IReduxState } from './types';
*
* @param {(Function|Object)} stateful - THe redux store, state, or
* {@code getState} function.
* @param {Dispatch} dispatch - The Redux dispatch function.
* @returns {Promise<Object>}
*/
export function _getRouteToRender(stateful: IStateful) {
export function _getRouteToRender(stateful: IStateful): Promise<object> {
const state = toState(stateful);
return _getWebConferenceRoute(state) || _getWebWelcomePageRoute(state);
@ -36,9 +34,10 @@ export function _getRouteToRender(stateful: IStateful) {
* a valid conference is being joined.
*
* @param {Object} state - The redux state.
* @param {Dispatch} dispatch - The Redux dispatch function.
* @returns {Promise|undefined}
*/
function _getWebConferenceRoute(state: IReduxState) {
function _getWebConferenceRoute(state: IReduxState): Promise<any> | undefined {
const room = state['features/base/conference'].room;
if (!isRoomValid(room)) {
@ -46,36 +45,6 @@ function _getWebConferenceRoute(state: IReduxState) {
}
const route = _getEmptyRoute();
const config = state['features/base/config'];
// if we have auto redirect enabled, and we have previously logged in successfully
// let's redirect to the auth url to get the token and login again
if (!browser.isElectron() && config.tokenAuthUrl && config.tokenAuthUrlAutoRedirect
&& state['features/authentication'].tokenAuthUrlSuccessful
&& !state['features/base/jwt'].jwt && room) {
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
const { tenant } = parseURIString(locationURL.href) || {};
const { startAudioOnly } = config;
return getTokenAuthUrl(
config,
locationURL,
{
audioMuted: false,
audioOnlyEnabled: startAudioOnly,
skipPrejoin: false,
videoMuted: false
},
room,
tenant
)
.then((url: string | undefined) => {
route.href = url;
return route;
})
.catch(() => Promise.resolve(route));
}
// Update the location if it doesn't match. This happens when a room is
// joined from the welcome page. The reason for doing this instead of using

@ -1,4 +1,5 @@
import '../base/app/middleware';
import '../base/jwt/middleware.web';
import '../base/config/middleware';
import '../base/connection/middleware';
import '../base/devices/middleware';

@ -88,3 +88,13 @@ export function openTokenAuthUrl(tokenAuthServiceUrl: string) {
Linking.openURL(tokenAuthServiceUrl);
};
}
/**
* Not used.
*
* @param {string} tokenAuthServiceUrl - Authentication service URL.
* @returns {Promise<any>} Resolves.
*/
export function loginWithPopup(tokenAuthServiceUrl: string): Promise<any> {
return Promise.resolve(tokenAuthServiceUrl);
}

@ -1,10 +1,14 @@
import { maybeRedirectToWelcomePage } from '../app/actions.web';
import { IStore } from '../app/types';
import { openDialog } from '../base/dialog/actions';
import { setJWT } from '../base/jwt/actions';
import { browser } from '../base/lib-jitsi-meet';
import { showErrorNotification } from '../notifications/actions';
import { CANCEL_LOGIN } from './actionTypes';
import LoginQuestionDialog from './components/web/LoginQuestionDialog';
import { isTokenAuthInline } from './functions.any';
import logger from './logger';
export * from './actions.any';
@ -46,6 +50,147 @@ export function redirectToDefaultLocation() {
return (dispatch: IStore['dispatch']) => dispatch(maybeRedirectToWelcomePage());
}
/**
* Generates a cryptographic nonce.
*
* @returns {string} The generated nonce.
*/
function generateNonce(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
/**
* Performs login with a popup window.
*
* @param {string} tokenAuthServiceUrl - Authentication service URL.
* @returns {Promise<any>} A promise that resolves with the authentication
* result or rejects with an error.
*/
export function loginWithPopup(tokenAuthServiceUrl: string): Promise<any> {
return new Promise<any>((resolve, reject) => {
// Open popup
const width = 500;
const height = 600;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
let nonceParam = '';
try {
const nonce = generateNonce();
sessionStorage.setItem('oauth_nonce', nonce);
nonceParam = `&nonce=${nonce}`;
} catch (e) {
if (e instanceof DOMException && e.name === 'SecurityError') {
logger.warn(
'sessionStorage access denied (cross-origin or restricted context) enable it to improve security',
e);
} else {
logger.error('Unable to save nonce in session storage', e);
}
}
const popup = window.open(
`${tokenAuthServiceUrl}${nonceParam}`,
`Auth-${Date.now()}`,
`width=${width},height=${height},left=${left},top=${top}`
);
if (!popup) {
reject(new Error('Popup blocked'));
return;
}
let closedPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
const cleanup = (handler: (event: MessageEvent) => void) => {
window.removeEventListener('message', handler);
clearInterval(closedPollInterval);
popup.close();
try {
sessionStorage.removeItem('oauth_nonce');
} catch (e) {
// ignore
}
try {
sessionStorage.removeItem('code_verifier');
} catch (e) {
// ignore
}
};
const handler = (event: MessageEvent) => {
// Verify origin
if (event.origin !== window.location.origin) {
return;
}
if (event.data.type === 'oauth-success') {
cleanup(handler);
resolve({
accessToken: event.data.accessToken,
idToken: event.data.idToken,
refreshToken: event.data.refreshToken
});
} else if (event.data.type === 'oauth-error') {
cleanup(handler);
reject(new Error(event.data.error));
}
};
// Listen for messages from the popup
window.addEventListener('message', handler);
// Detect manual popup close before authentication completes
closedPollInterval = setInterval(() => {
if (popup.closed) {
cleanup(handler);
reject(new Error('Login cancelled'));
}
}, 500);
});
}
/**
* Performs silent logout by loading the token authentication logout service URL in an
* invisible iframe.
*
* @param {string} tokenAuthLogoutServiceUrl - Logout service URL.
* @returns {Promise<any>} A promise that resolves when logout is complete.
*/
export function silentLogout(tokenAuthLogoutServiceUrl: string): any {
return new Promise<void>(resolve => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = tokenAuthLogoutServiceUrl;
document.body.appendChild(iframe);
// Listen for logout completion
const handler = (event: any) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === 'logout-success') {
window.removeEventListener('message', handler);
document.body.removeChild(iframe);
resolve();
}
};
window.addEventListener('message', handler);
});
}
/**
* Opens token auth URL page.
*
@ -63,6 +208,42 @@ export function openTokenAuthUrl(tokenAuthServiceUrl: string): any {
}
};
if (!browser.isElectron() && isTokenAuthInline(getState()['features/base/config'])) {
loginWithPopup(tokenAuthServiceUrl)
.then((result: { accessToken: string; idToken: string; refreshToken?: string; }) => {
// @ts-ignore
const token: string = result.accessToken;
const idToken: string = result.idToken;
const refreshToken: string | undefined = result.refreshToken;
// @ts-ignore
dispatch(setJWT(token, idToken, refreshToken));
logger.info('Reconnecting to conference with new token.');
const { connection } = getState()['features/base/connection'];
connection?.refreshToken(token).then(
() => {
const { membersOnly } = getState()['features/base/conference'];
membersOnly?.join();
})
.catch((err: any) => {
dispatch(setJWT());
logger.error(err);
});
})
.catch(err => {
dispatch(showErrorNotification({
titleKey: 'dialog.loginFailed'
}));
logger.error(err);
});
return;
}
// Show warning for leaving conference only when in a conference.
if (!browser.isElectron() && getState()['features/base/conference'].conference) {
dispatch(openDialog('LoginQuestionDialog', LoginQuestionDialog, {

@ -1,15 +1,30 @@
import { IReduxState } from '../app/types';
import { IConfig } from '../base/config/configType';
import { parseURLParams } from '../base/util/parseURLParams';
import { getBackendSafeRoomName } from '../base/util/uri';
import { isVpaasMeeting } from '../jaas/functions';
/**
* Checks if the token for authentication is available.
* Checks if the token for authentication URL is available and the meeting is not jaas.
*
* @param {IReduxState} state - The state of the app.
* @returns {boolean}
*/
export const isTokenAuthEnabled = (state: IReduxState): boolean => {
const config = state['features/base/config'];
return typeof config.tokenAuthUrl === 'string' && config.tokenAuthUrl.length > 0
&& !isVpaasMeeting(state);
};
/**
* Checks if the token authentication should be done inline.
*
* @param {Object} config - Configuration state object from store.
* @returns {boolean}
*/
export const isTokenAuthEnabled = (config: IConfig): boolean =>
typeof config.tokenAuthUrl === 'string' && config.tokenAuthUrl.length > 0;
export const isTokenAuthInline = (config: IConfig): boolean =>
config.tokenAuthInline === true;
/**
* Returns the state that we can add as a parameter to the tokenAuthUrl.
@ -23,6 +38,7 @@ export const isTokenAuthEnabled = (config: IConfig): boolean =>
* }.
* @param {string?} roomName - The room name.
* @param {string?} tenant - The tenant name if any.
* @param {string?} refreshToken - The refresh token if available.
*
* @returns {Object} The state object.
*/
@ -35,8 +51,10 @@ export const _getTokenAuthState = (
videoMuted: boolean | undefined;
},
roomName: string | undefined,
tenant: string | undefined): object => {
tenant: string | undefined,
refreshToken?: string): object => {
const state = {
refreshToken,
room: roomName,
roomSafe: getBackendSafeRoomName(roomName),
tenant

@ -23,6 +23,7 @@ export * from './functions.any';
* }.
* @param {string?} roomName - The room name.
* @param {string?} tenant - The tenant name if any.
* @param {string?} refreshToken - The refreshToken if any.
*
* @returns {Promise<string|undefined>} - The URL pointing to JWT login service or
* <tt>undefined</tt> if the pattern stored in config is not a string and the URL can not be
@ -39,7 +40,9 @@ export const getTokenAuthUrl = (
},
roomName: string | undefined,
// eslint-disable-next-line max-params
tenant: string | undefined): Promise<string | undefined> => {
tenant: string | undefined,
// eslint-disable-next-line max-params
refreshToken?: string | undefined): Promise<string | undefined> => {
const {
audioMuted = false,
@ -64,7 +67,8 @@ export const getTokenAuthUrl = (
videoMuted
},
roomName,
tenant
tenant,
refreshToken
);
// Append ios=true or android=true to the token URL.

@ -4,6 +4,7 @@ import { IConfig } from '../base/config/configType';
import { browser } from '../base/lib-jitsi-meet';
import { _getTokenAuthState } from './functions.any';
import logger from './logger';
export * from './functions.any';
@ -41,6 +42,7 @@ function _cryptoRandom() {
* }.
* @param {string?} roomName - The room name.
* @param {string?} tenant - The tenant name if any.
* @param {string?} refreshToken - The refresh token if available.
*
* @returns {Promise<string|undefined>} - The URL pointing to JWT login service or
* <tt>undefined</tt> if the pattern stored in config is not a string and the URL can not be
@ -56,9 +58,10 @@ export const getTokenAuthUrl = (
videoMuted: boolean | undefined;
},
roomName: string | undefined,
// eslint-disable-next-line max-params
tenant: string | undefined): Promise<string | undefined> => {
// eslint-disable max-params
tenant: string | undefined,
refreshToken?: string): Promise<string | undefined> => {
// eslint-enable max-params
const {
audioMuted = false,
audioOnlyEnabled = false,
@ -82,7 +85,8 @@ export const getTokenAuthUrl = (
videoMuted
},
roomName,
tenant
tenant,
refreshToken
);
if (browser.isElectron()) {
@ -103,7 +107,17 @@ export const getTokenAuthUrl = (
codeVerifier += POSSIBLE_CHARS.charAt(Math.floor(_cryptoRandom() * POSSIBLE_CHARS.length));
}
window.sessionStorage.setItem('code_verifier', codeVerifier);
try {
window.sessionStorage.setItem('code_verifier', codeVerifier);
} catch (e) {
if (e instanceof DOMException && e.name === 'SecurityError') {
logger.warn(
'sessionStorage access denied (cross-origin or restricted context) enable it to improve security',
e);
} else {
logger.error('Unable to save code verifier in session storage', e);
}
}
return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
.then(digest => {

@ -1,5 +1,4 @@
import { IStore } from '../app/types';
import { APP_WILL_NAVIGATE } from '../base/app/actionTypes';
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
@ -17,6 +16,7 @@ import { MEDIA_TYPE } from '../base/media/constants';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { isLocalTrackMuted } from '../base/tracks/functions.any';
import { parseURIString } from '../base/util/uri';
import { PREJOIN_JOINING_IN_PROGRESS } from '../prejoin/actionTypes';
import { openLogoutDialog } from '../settings/actions';
import {
@ -130,7 +130,7 @@ MiddlewareRegistry.register(store => next => action => {
const state = getState();
const config = state['features/base/config'];
if (isTokenAuthEnabled(config)
if (isTokenAuthEnabled(state)
&& config.tokenAuthUrlAutoRedirect
&& state['features/base/jwt'].jwt) {
// auto redirect is turned on and we have successfully logged in
@ -187,7 +187,11 @@ MiddlewareRegistry.register(store => next => action => {
break;
}
case APP_WILL_NAVIGATE: {
case PREJOIN_JOINING_IN_PROGRESS: {
if (!action.value) {
break;
}
const { dispatch, getState } = store;
const state = getState();
const config = state['features/base/config'];
@ -288,6 +292,7 @@ function _handleLogin({ dispatch, getState }: IStore) {
const { enabled: audioOnlyEnabled } = state['features/base/audio-only'];
const audioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
const videoMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
const refreshToken = state['features/base/jwt'].refreshToken;
if (!room) {
logger.warn('Cannot handle login, room is undefined!');
@ -295,7 +300,7 @@ function _handleLogin({ dispatch, getState }: IStore) {
return;
}
if (!isTokenAuthEnabled(config)) {
if (!isTokenAuthEnabled(state)) {
dispatch(openLoginDialog());
return;
@ -311,7 +316,8 @@ function _handleLogin({ dispatch, getState }: IStore) {
videoMuted
},
room,
tenant
tenant,
refreshToken
)
.then((tokenAuthServiceUrl: string | undefined) => {
if (!tokenAuthServiceUrl) {

@ -354,7 +354,7 @@ export function e2eRttChanged(participant: Object, rtt: number) {
* authLogin: string
* }}
*/
export function authStatusChanged(authEnabled: boolean, authLogin: string) {
export function authStatusChanged(authEnabled: boolean, authLogin?: string) {
return {
type: AUTH_STATUS_CHANGED,
authEnabled,

@ -14,6 +14,7 @@ import { sendAnalytics } from '../../analytics/functions';
import { reloadNow } from '../../app/actions';
import { IStore } from '../../app/types';
import { login } from '../../authentication/actions.any';
import { isTokenAuthEnabled } from '../../authentication/functions.any';
import { removeLobbyChatParticipant } from '../../chat/actions.any';
import { openDisplayNamePrompt } from '../../display-name/actions';
import { isVpaasMeeting } from '../../jaas/functions';
@ -266,8 +267,11 @@ function _conferenceFailed({ dispatch, getState }: IStore, next: Function, actio
descriptionKey = 'dialog.errorRoomCreationRestriction';
} else if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.ROOM_UNAUTHENTICATED_ACCESS_DISABLED) {
titleKey = 'dialog.unauthenticatedAccessDisabled';
customActionNameKey = [ 'toolbar.login' ];
customActionHandler = [ () => dispatch(login()) ];
if (isTokenAuthEnabled(getState())) {
customActionNameKey = [ 'toolbar.login' ];
customActionHandler = [ () => dispatch(login()) ]; // show login button if not jaas
}
}
dispatch(showErrorNotification({
@ -402,7 +406,8 @@ async function _connectionEstablished({ dispatch, getState }: IStore, next: Func
email = getLocalParticipant(getState())?.email;
}
dispatch(authStatusChanged(true, email || ''));
// it may happen to be already set
dispatch(authStatusChanged(true, email || getState()['features/base/conference'].authLogin || ''));
}
// FIXME: Workaround for the web version. Currently, the creation of the

@ -616,6 +616,7 @@ export interface IConfig {
disabled?: boolean;
numberOfVisibleTiles?: number;
};
tokenAuthInline?: boolean;
tokenAuthUrl?: string;
tokenAuthUrlAutoRedirect?: string;
tokenGetUserInfoOutOfContext?: boolean;

@ -51,6 +51,16 @@ export const CONNECTION_PROPERTIES_UPDATED = 'CONNECTION_PROPERTIES_UPDATED';
*/
export const CONNECTION_WILL_CONNECT = 'CONNECTION_WILL_CONNECT';
/**
* The type of (redux) action which signals that the token for a connection is expired.
*
* {
* type: CONNECTION_TOKEN_EXPIRED,
* connection: JitsiConnection
* }
*/
export const CONNECTION_TOKEN_EXPIRED = 'CONNECTION_TOKEN_EXPIRED';
/**
* The type of (redux) action which sets the location URL of the application,
* connection, conference, etc.

@ -17,6 +17,7 @@ import {
CONNECTION_ESTABLISHED,
CONNECTION_FAILED,
CONNECTION_PROPERTIES_UPDATED,
CONNECTION_TOKEN_EXPIRED,
CONNECTION_WILL_CONNECT,
SET_LOCATION_URL,
SET_PREFER_VISITOR
@ -239,6 +240,9 @@ export function _connectInternal(id?: string, password?: string) {
connection.addEventListener(
JitsiConnectionEvents.PROPERTIES_UPDATED,
_onPropertiesUpdate);
connection.addEventListener(
JitsiConnectionEvents.CONNECTION_TOKEN_EXPIRED,
_onTokenExpired);
/**
* Unsubscribe the connection instance from
@ -323,6 +327,16 @@ export function _connectInternal(id?: string, password?: string) {
dispatch(redirect(vnode, focusJid, username));
}
/**
* Connection will resume.
*
* @private
* @returns {void}
*/
function _onTokenExpired(): void {
dispatch(_connectionTokenExpired(connection));
}
/**
* Connection properties were updated.
*
@ -364,6 +378,23 @@ function _connectionWillConnect(connection: Object) {
};
}
/**
* Create an action for when a connection token is expired.
*
* @param {JitsiConnection} connection - The {@code JitsiConnection} token is expired.
* @private
* @returns {{
* type: CONNECTION_TOKEN_EXPIRED,
* connection: JitsiConnection
* }}
*/
function _connectionTokenExpired(connection: Object) {
return {
type: CONNECTION_TOKEN_EXPIRED,
connection
};
}
/**
* Create an action for when connection properties are updated.
*

@ -23,6 +23,7 @@ export interface IConnectionState {
getJid: () => string;
getLogs: () => Object;
initJitsiConference: Function;
refreshToken: Function;
removeFeature: Function;
};
error?: ConnectionFailedError;

@ -20,15 +20,21 @@ export function setDelayedLoadOfAvatarUrl(avatarUrl?: string) {
* Stores a specific JSON Web Token (JWT) into the redux store.
*
* @param {string} [jwt] - The JSON Web Token (JWT) to store.
* @param {string} idToken - The ID Token to store.
* @param {string} refreshToken - The Refresh Token to store.
* @returns {{
* type: SET_JWT,
* jwt: (string|undefined)
* jwt: (string|undefined),
* idToken: (string|undefined),
* refreshToken: (string|undefined)
* }}
*/
export function setJWT(jwt?: string) {
export function setJWT(jwt?: string, idToken?: string, refreshToken?: string) {
return {
type: SET_JWT,
jwt
jwt,
idToken,
refreshToken
};
}

@ -4,6 +4,7 @@ import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import { isVpaasMeeting } from '../../jaas/functions';
import { authStatusChanged } from '../conference/actions.any';
import { getCurrentConference } from '../conference/functions';
import { SET_CONFIG } from '../config/actionTypes';
import { CONNECTION_ESTABLISHED, SET_LOCATION_URL } from '../connection/actionTypes';
@ -39,6 +40,8 @@ StateListenerRegistry.register(
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const state = store.getState();
switch (action.type) {
case SET_CONFIG:
case SET_LOCATION_URL:
@ -46,7 +49,6 @@ MiddlewareRegistry.register(store => next => action => {
// 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) {
@ -56,6 +58,7 @@ MiddlewareRegistry.register(store => next => action => {
store.dispatch(setDelayedLoadOfAvatarUrl());
store.dispatch(setKnownAvatarUrl(delayedLoadOfAvatarUrl));
}
break;
}
case SET_JWT:
return _setJWT(store, next, action);
@ -149,7 +152,7 @@ function _setConfigOrLocationURL({ dispatch, getState }: IStore, next: Function,
*/
function _setJWT(store: IStore, next: Function, action: AnyAction) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { jwt, type, ...actionPayload } = action;
const { idToken, jwt, refreshToken, type, ...actionPayload } = action;
if (!Object.keys(actionPayload).length) {
const state = store.getState();
@ -210,24 +213,32 @@ function _setJWT(store: IStore, next: Function, action: AnyAction) {
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 (jwtPayload.name || jwtPayload.picture || jwtPayload.email) {
if (tokenGetUserInfoOutOfContext) {
// there are some tokens (firebase) having picture and name on the main level.
_overwriteLocalParticipant(store, {
avatarURL: jwtPayload.picture,
name: jwtPayload.name,
email: jwtPayload.email
});
}
store.dispatch(authStatusChanged(true, 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.
} 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'];
const { user } = state['features/base/jwt'];
user && _undoOverwriteLocalParticipant(store, user);
}
user && _undoOverwriteLocalParticipant(store, user);
// clears authLogin
store.dispatch(authStatusChanged(true));
}
}

@ -0,0 +1,100 @@
import { IStore } from '../../app/types';
import { loginWithPopup } from '../../authentication/actions';
import LoginQuestionDialog from '../../authentication/components/web/LoginQuestionDialog';
import { getTokenAuthUrl, isTokenAuthEnabled, isTokenAuthInline } from '../../authentication/functions';
import { hideNotification, showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../../notifications/constants';
import { CONNECTION_TOKEN_EXPIRED } from '../connection/actionTypes';
import { openDialog } from '../dialog/actions';
import { browser } from '../lib-jitsi-meet';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { parseURIString } from '../util/uri';
import { setJWT } from './actions';
import logger from './logger';
const PROMPT_LOGIN_NOTIFICATION_ID = 'PROMPT_LOGIN_NOTIFICATION_ID';
/**
* Middleware to handle token expiration on web - prompts the user to re-authenticate.
*
* @param {Store} store - The redux store.
* @private
* @returns {Function}
*/
MiddlewareRegistry.register((store: IStore) => next => action => {
if (action.type === CONNECTION_TOKEN_EXPIRED) {
const state = store.getState();
const jwt = state['features/base/jwt'].jwt;
const refreshToken = state['features/base/jwt'].refreshToken;
if (typeof APP !== 'undefined' && jwt && isTokenAuthEnabled(state)) {
const { connection, locationURL = { href: '' } as URL } = state['features/base/connection'];
const { tenant } = parseURIString(locationURL.href) || {};
const room = state['features/base/conference'].room;
const dispatch = store.dispatch;
getTokenAuthUrl(
state['features/base/config'],
locationURL,
{
audioMuted: false,
audioOnlyEnabled: false,
skipPrejoin: false,
videoMuted: false
},
room,
tenant,
refreshToken
)
.then((url: string | undefined) => {
if (url) {
dispatch(showNotification({
descriptionKey: 'dialog.loginOnResume',
titleKey: 'dialog.login',
uid: PROMPT_LOGIN_NOTIFICATION_ID,
customActionNameKey: [ 'dialog.login' ],
customActionHandler: [ () => {
store.dispatch(hideNotification(PROMPT_LOGIN_NOTIFICATION_ID));
if (isTokenAuthInline(state['features/base/config'])) {
loginWithPopup(url)
.then((result: { accessToken: string; idToken: string; refreshToken?: string; }) => {
const token: string = result.accessToken;
const idToken: string = result.idToken;
const newRefreshToken: string | undefined = result.refreshToken;
dispatch(setJWT(token, idToken, newRefreshToken || refreshToken));
connection?.refreshToken(token)
.catch((err: any) => {
dispatch(setJWT());
logger.error(err);
});
}).catch(logger.error);
} else {
dispatch(openDialog('LoginQuestionDialog', LoginQuestionDialog, {
handler: () => {
// Give time for the dialog to close.
setTimeout(() => {
if (browser.isElectron()) {
window.open(url, '_blank');
} else {
window.location.href = url;
}
}, 500);
}
}));
}
} ],
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}
})
.catch(logger.error);
}
}
return next(action);
});

@ -11,8 +11,10 @@ export interface IJwtState {
};
delayedLoadOfAvatarUrl?: string;
group?: string;
idToken?: string;
jwt?: string;
knownAvatarUrl?: string;
refreshToken?: string;
server?: string;
tenant?: string;
user?: {

@ -5,9 +5,14 @@ import { connect, useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { loginWithPopup } from '../../../authentication/actions.web';
import { getTokenAuthUrl, isTokenAuthEnabled, isTokenAuthInline } from '../../../authentication/functions.web';
import Avatar from '../../../base/avatar/components/Avatar';
import { IConfig } from '../../../base/config/configType';
import { isNameReadOnly } from '../../../base/config/functions.web';
import { IconArrowDown, IconArrowUp, IconPhoneRinging, IconVolumeOff } from '../../../base/icons/svg';
import { setJWT } from '../../../base/jwt/actions';
import { browser } from '../../../base/lib-jitsi-meet';
import { isVideoMutedByUser } from '../../../base/media/functions';
import { getLocalParticipant } from '../../../base/participants/functions';
import Popover from '../../../base/popover/components/Popover.web';
@ -20,6 +25,7 @@ import Button from '../../../base/ui/components/web/Button';
import Input from '../../../base/ui/components/web/Input';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import isInsecureRoomName from '../../../base/util/isInsecureRoomName';
import { parseURIString } from '../../../base/util/uri';
import { openDisplayNamePrompt } from '../../../display-name/actions';
import { isUnsafeRoomWarningEnabled } from '../../../prejoin/functions';
import {
@ -121,6 +127,16 @@ interface IProps {
*/
showUnsafeRoomWarning: boolean;
/**
* The configuration for token pre-authentication, if applicable.
*/
tokenPreAuthConfig?: {
config: IConfig;
locationURL: URL;
refreshToken: string | undefined;
room: string;
};
/**
* Whether the user has approved to join a room with unsafe name.
*/
@ -226,6 +242,7 @@ const Prejoin = ({
showErrorOnJoin,
showRecordingWarning,
showUnsafeRoomWarning,
tokenPreAuthConfig,
unsafeRoomConsent,
updateSettings: dispatchUpdateSettings,
videoTrack
@ -259,7 +276,56 @@ const Prejoin = ({
logger.info('Prejoin join button clicked.');
joinConference();
// if we have auto redirect enabled, and we have previously logged in successfully
// let's redirect to the auth url to get the token and login again
if (tokenPreAuthConfig) {
const { tenant } = parseURIString(tokenPreAuthConfig.locationURL.href) || {};
const { startAudioOnly } = tokenPreAuthConfig.config;
const refreshToken = tokenPreAuthConfig.refreshToken;
getTokenAuthUrl(
config,
tokenPreAuthConfig.locationURL,
{
audioMuted: false,
audioOnlyEnabled: startAudioOnly,
skipPrejoin: false,
videoMuted: false
},
tokenPreAuthConfig.room,
tenant,
refreshToken
)
.then((url: string | undefined) => {
if (isTokenAuthInline(config)) {
if (url) {
return loginWithPopup(url)
.then((result: { accessToken: string; idToken: string; refreshToken?: string; }) => {
// @ts-ignore
const token: string = result.accessToken;
const idToken: string = result.idToken;
const newRefreshToken: string | undefined = result.refreshToken;
// @ts-ignore
dispatch(setJWT(token, idToken, newRefreshToken || refreshToken));
})
.then(() => joinConference());
}
} else {
if (url) {
window.location.href = url;
} else {
joinConference();
}
}
})
.catch(err => {
logger.error('Error in login', err);
joinConference();
});
} else {
joinConference();
}
};
/**
@ -502,7 +568,13 @@ function mapStateToProps(state: IReduxState) {
const { joiningInProgress } = state['features/prejoin'];
const { room } = state['features/base/conference'];
const { unsafeRoomConsent } = state['features/base/premeeting'];
const { showPrejoinWarning: showRecordingWarning } = state['features/base/config'].recordings ?? {};
const config = state['features/base/config'];
const { showPrejoinWarning: showRecordingWarning } = config.recordings ?? {};
const preTokenAuthenticate = !browser.isElectron()
&& isTokenAuthEnabled(state)
&& config.tokenAuthUrlAutoRedirect && state['features/authentication'].tokenAuthUrlSuccessful
&& !state['features/base/jwt'].jwt && room; // skip if jaas
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
return {
deviceStatusVisible: isDeviceStatusVisible(state),
@ -518,6 +590,12 @@ function mapStateToProps(state: IReduxState) {
showErrorOnJoin,
showRecordingWarning: Boolean(showRecordingWarning),
showUnsafeRoomWarning: isInsecureRoomName(room) && isUnsafeRoomWarningEnabled(state),
tokenPreAuthConfig: preTokenAuthenticate ? {
config,
locationURL,
refreshToken: state['features/base/jwt'].refreshToken,
room
} : undefined,
unsafeRoomConsent,
videoTrack: getLocalJitsiVideoTrack(state)
};

@ -18,12 +18,11 @@ export function openLogoutDialog() {
const state = getState();
const { conference } = state['features/base/conference'];
const config = state['features/base/config'];
const logoutUrl = config.tokenLogoutUrl;
const logoutUrl = state['features/base/config'].tokenLogoutUrl;
dispatch(openDialog('LogoutDialog', LogoutDialog, {
onLogout() {
if (isTokenAuthEnabled(config)) {
if (isTokenAuthEnabled(state)) {
if (logoutUrl) {
Linking.openURL(logoutUrl);
}

@ -1,8 +1,8 @@
import { batch } from 'react-redux';
import { IStore } from '../app/types';
import { setTokenAuthUrlSuccess } from '../authentication/actions.web';
import { isTokenAuthEnabled } from '../authentication/functions';
import { setTokenAuthUrlSuccess, silentLogout } from '../authentication/actions.web';
import { isTokenAuthEnabled, isTokenAuthInline } from '../authentication/functions';
import {
setStartMutedPolicy,
setStartReactionsMuted
@ -11,6 +11,7 @@ import { getConferenceState } from '../base/conference/functions';
import { hangup } from '../base/connection/actions.web';
import { openDialog } from '../base/dialog/actions';
import i18next from '../base/i18n/i18next';
import { setJWT } from '../base/jwt/actions';
import { browser } from '../base/lib-jitsi-meet';
import { getNormalizedDisplayName } from '../base/participants/functions';
import { updateSettings } from '../base/settings/actions';
@ -37,6 +38,7 @@ import {
getProfileTabProps,
getShortcutsTabProps
} from './functions.web';
import logger from './logger';
/**
* Opens {@code LogoutDialog}.
@ -51,11 +53,28 @@ export function openLogoutDialog() {
const logoutUrl = config.tokenLogoutUrl;
const { conference } = state['features/base/conference'];
const { jwt } = state['features/base/jwt'];
const { jwt, idToken } = state['features/base/jwt'];
if (!browser.isElectron() && logoutUrl && isTokenAuthInline(config)) {
let url = logoutUrl;
if (idToken) {
url += `${logoutUrl.indexOf('?') === -1 ? '?' : '&'}id_token_hint=${idToken}`;
}
silentLogout(url)
.then(() => {
dispatch(setJWT());
dispatch(setTokenAuthUrlSuccess(false));
})
.catch(() => logger.error('logout failed'));
return;
}
dispatch(openDialog('LogoutDialog', LogoutDialog, {
onLogout() {
if (isTokenAuthEnabled(config) && config.tokenAuthUrlAutoRedirect && jwt) {
if (isTokenAuthEnabled(state) && config.tokenAuthUrlAutoRedirect && jwt) {
// user is logging out remove auto redirect indication
dispatch(setTokenAuthUrlSuccess(false));

@ -272,6 +272,13 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
component: ProfileTab,
labelKey: 'profile.title',
props: getProfileTabProps(state),
propsUpdateFunction: (tabState: any, newProps: ReturnType<typeof getProfileTabProps>) => {
return {
...newProps,
displayName: tabState?.displayName,
email: tabState?.email
};
},
submit: submitProfileTab,
icon: IconUser
});

@ -89,5 +89,27 @@
- speakerStats - A table containing speaker statistics for occupants in the room. The keys are occupant JIDs and the values are objects with properties like dominantSpeakerId, faceLandmarks, and sessionId. Used by mod_speakerstats_component.lua to manage speaker statistics in the room.
- visitors_destroy_timer - A timer used to destroy the room when there are no main occupants or visitors left. It is set by mod_fmuc.lua to clean up the room after a certain period of inactivity.
# session fields added by jitsi
- jitsi_meet_context_user - The context from the jwt token, added after token verify.
- jitsi_meet_context_group - The group from the jwt context, added after token verify.
- jitsi_meet_context_features - The features from the context, added after token verify.
- jitsi_meet_context_room - The room settings from the jwt context, added after token verify.
- jitsi_meet_room - The room name in jwt token, added after token verify.
- jitsi_meet_str_tenant - The tenant in the context. Added after token verify.
- jitsi_meet_domain - The domain in the jwt ('sub' claim). Added after token verify. Can be the domain if not tenant is used or the tenant itself in lowercase.
- customusername - from a query parameter to be used with combination with "pre-jitsi-authentication" event to pre-set a known jid to a session.
- jitsi_web_query_room - room name from the query.
- jitsi_web_query_prefix - the tenant from the query specified as a param named 'prefix'.
- auth_token - The token, set before verify and cleared if verification fails.
- jitsi_meet_tenant_mismatch - The tenant field from the token and the query param for tenant do not match.
- previd - Used for stream resumption.
- user_region - the region header from the http request received.
- user_agent_header - the user agent header from the http request received.
- jitsi_throttle - used by rate limit module.
- jitsi_throttle_counter - used by rate limit module.
- force_permissions_update - Indicate that on next self-presence update the permissions should be resent to the client. Used by mod_jitsi_permissions.lua to manage permissions updates for the session.
- granted_jitsi_meet_context_user_id - when affiliation was changed (grant moderation) this holds the id of the actor.
- granted_jitsi_meet_context_group_id - when affiliation was changed (grant moderation) this holds the group of the actor.
#### Notes:
When modules need to store data they should do it in the room object in _data or directly. The data needs to be a simple as strings or table of strings, they should not add objects like room, sessions or occupants that cannot be serialized. Attaching data to the room object makes reloading modules safe and guarantees data will be wiped once the room is destroyed.

@ -7,6 +7,8 @@ local new_sasl = require "util.sasl".new;
local sasl = require "util.sasl";
local sessions = prosody.full_sessions;
module:depends("jitsi_session");
-- define auth provider
local provider = {};
@ -38,10 +40,13 @@ function provider.get_sasl_handler(session)
-- Custom session matching so we can resume session even with randomly
-- generated user IDs.
local function get_username(self, message)
local resuming = false;
if (session.previd ~= nil) then
for _, session1 in pairs(sessions) do
if (session1.resumption_token == session.previd) then
self.username = session1.username;
resuming = true;
break;
end
end
@ -49,6 +54,10 @@ function provider.get_sasl_handler(session)
self.username = message;
end
if not resuming then
session.auth_token = nil;
end
return true;
end

@ -26,41 +26,6 @@ local provider = {};
local host = module.host;
-- Extract 'token' param from URL when session is created
function init_session(event)
local session, request = event.session, event.request;
local query = request.url.query;
local token = nil;
-- extract token from Authorization header
if request.headers["authorization"] then
-- assumes the header value starts with "Bearer "
token = request.headers["authorization"]:sub(8,#request.headers["authorization"])
end
-- allow override of token via query parameter
if query ~= nil then
local params = formdecode(query);
-- The following fields are filled in the session, by extracting them
-- from the query and no validation is being done.
-- After validating auth_token will be cleaned in case of error and few
-- other fields will be extracted from the token and set in the session
if params and params.token then
token = params.token;
end
end
-- in either case set auth_token in the session
session.auth_token = token;
session.user_agent_header = request.headers['user_agent'];
end
module:hook_global("bosh-session", init_session);
module:hook_global("websocket-session", init_session);
module:hook("pre-resource-unbind", function (e)
local error, session = e.error, e.session;
@ -95,41 +60,60 @@ function provider.delete_user(username)
return nil;
end
function provider.get_sasl_handler(session)
function first_stage_auth(session)
-- retrieve custom public key from server and save it on the session
local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session);
if pre_event_result ~= nil and pre_event_result.res == false then
module:log("warn",
"Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason);
session.auth_token = nil;
measure_pre_fetch_fail(1);
return pre_event_result;
end
local function get_username_from_token(self, message)
local res, error, reason = token_util:process_and_verify_token(session);
if res == false then
module:log("warn",
"Error verifying token err:%s, reason:%s tenant:%s room:%s user_agent:%s",
error, reason, session.jitsi_web_query_prefix, session.jitsi_web_query_room,
session.user_agent_header);
session.auth_token = nil;
measure_verify_fail(1);
return { res = res, error = error, reason = reason };
end
-- retrieve custom public key from server and save it on the session
local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session);
if pre_event_result ~= nil and pre_event_result.res == false then
module:log("warn",
"Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason);
session.auth_token = nil;
measure_pre_fetch_fail(1);
return pre_event_result.res, pre_event_result.error, pre_event_result.reason;
end
local shouldAllow = prosody.events.fire_event("jitsi-access-ban-check", session);
if shouldAllow == false then
module:log("warn", "user is banned")
measure_ban(1);
return { res = false, error = "not-allowed", reason = "user is banned" };
end
local res, error, reason = token_util:process_and_verify_token(session);
if res == false then
module:log("warn",
"Error verifying token err:%s, reason:%s tenant:%s room:%s user_agent:%s",
error, reason, session.jitsi_web_query_prefix, session.jitsi_web_query_room,
session.user_agent_header);
session.auth_token = nil;
measure_verify_fail(1);
return res, error, reason;
end
return { verify_result = res, custom_username = prosody.events.fire_event("pre-jitsi-authentication", session) };
end
local shouldAllow = prosody.events.fire_event("jitsi-access-ban-check", session);
if shouldAllow == false then
module:log("warn", "user is banned")
measure_ban(1);
return false, "not-allowed", "user is banned";
function second_stage_auth(session)
local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session);
if post_event_result ~= nil and post_event_result.res == false then
module:log("warn",
"Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason);
session.auth_token = nil;
measure_post_auth_fail(1);
return post_event_result;
end
end
function provider.get_sasl_handler(session)
local function get_username_from_token(self, message)
local s1_result = first_stage_auth(session);
if s1_result.res == false then
return s1_result.res, s1_result.error, s1_result.reason;
end
local customUsername = prosody.events.fire_event("pre-jitsi-authentication", session);
if customUsername then
self.username = customUsername;
if s1_result.custom_username then
self.username = s1_result.custom_username;
elseif session.previd ~= nil then
for _, session1 in pairs(sessions) do
if (session1.resumption_token == session.previd) then
@ -141,17 +125,14 @@ function provider.get_sasl_handler(session)
self.username = message;
end
local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session);
if post_event_result ~= nil and post_event_result.res == false then
module:log("warn",
"Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason);
session.auth_token = nil;
measure_post_auth_fail(1);
return post_event_result.res, post_event_result.error, post_event_result.reason;
local s2_result = second_stage_auth(session);
if s2_result and s2_result.res ~= nil then
return s2_result.res, s2_result.error, s2_result.reason;
end
measure_success(1);
return res;
session._jitsi_auth_done = true;
return s1_result.verify_result;
end
return new_sasl(host, { anonymous = get_username_from_token });
@ -177,3 +158,47 @@ local function anonymous(self, message)
end
sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous);
module:hook_global('c2s-session-updated', function (event)
local session, from_session = event.session, event.from_session;
if not from_session.auth_token then
return;
end
-- we care to handle sessions from other hosts (anonymous hosts)
if module.host ~= event.from_session.host then
-- Handle session updates (e.g., when a session is resumed on some anonymous host with a token we need to do all the checks here)
session.auth_token = event.from_session.auth_token;
local s1_result = first_stage_auth(session);
if s1_result.res == false then
event.session:close();
return;
end
local s2_result = second_stage_auth(session);
if s2_result and s2_result.res == false then
event.session:close();
return;
end
session._jitsi_auth_done = true;
end
if not session._jitsi_auth_done then
module:log('warn', 'Impossible case hit where session did not pass auth flow');
event.session:close();
return;
end
-- copy all the custom fields we set in the session
session.auth_token = from_session.auth_token;
session.jitsi_meet_context_user = from_session.jitsi_meet_context_user;
session.jitsi_meet_context_group = from_session.jitsi_meet_context_group;
session.jitsi_meet_context_features = from_session.jitsi_meet_context_features;
session.jitsi_meet_context_room = from_session.jitsi_meet_context_room;
session.jitsi_meet_room = from_session.jitsi_meet_room;
session.jitsi_meet_str_tenant = from_session.jitsi_meet_str_tenant;
session.jitsi_meet_domain = from_session.jitsi_meet_domain;
session.jitsi_meet_tenant_mismatch = from_session.jitsi_meet_tenant_mismatch;
end, 1);

@ -11,6 +11,14 @@ function init_session(event)
local session, request = event.session, event.request;
local query = request.url.query;
local token = nil;
-- extract token from Authorization header
if request.headers["authorization"] then
-- assumes the header value starts with "Bearer "
token = request.headers["authorization"]:sub(8,#request.headers["authorization"])
end
if query ~= nil then
local params = formdecode(query);
@ -24,9 +32,23 @@ function init_session(event)
-- The room name and optional prefix from the web query
session.jitsi_web_query_room = params.room;
session.jitsi_web_query_prefix = params.prefix or "";
-- The following fields are filled in the session, by extracting them
-- from the query and no validation is being done.
-- After validating auth_token will be cleaned in case of error and few
-- other fields will be extracted from the token and set in the session
if params and params.token then
token = params.token;
end
end
session.user_region = request.headers[region_header_name];
-- in either case set auth_token in the session
session.auth_token = token;
session.user_agent_header = request.headers['user_agent'];
end
module:hook_global("bosh-session", init_session, 1);

@ -16,6 +16,7 @@ local is_transcriber = util.is_transcriber;
local QUEUE_MAX_SIZE = 500;
module:depends("jitsi_permissions");
module:depends("jitsi_session");
-- Common module for all logic that can be loaded under the conference muc component.
--

@ -57,7 +57,10 @@ module:hook('muc-occupant-pre-join', function (event)
local has_host = false;
for _, o in room:each_occupant() do
if jid.host(o.bare_jid) == muc_domain_base then
-- the main virtual host that requires tokens
if jid.host(o.bare_jid) == muc_domain_base
-- or this is anonymous that upgraded by passing token which we validated
or prosody.full_sessions[o.jid].auth_token then
room.has_host = true;
end
end

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logged Out</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f5f5f5;
}
.container {
text-align: center;
padding: 20px;
}
.success {
color: #27ae60;
font-size: 48px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="success"></div>
<p>You have been logged out successfully.</p>
</div>
<script>
// Notify parent window that logout is complete
if (window.parent) {
window.parent.postMessage({
type: 'logout-success'
}, window.location.origin);
}
</script>
</body>
</html>

@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSO Authentication</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f5f5f5;
}
.container {
text-align: center;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.spinner {
display: none;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
display: none;
color: #e74c3c;
margin-top: 20px;
}
.success {
display: none;
color: #27ae60;
font-size: 48px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<div id="spinner" class="spinner"></div>
<p id="spinner-msg" style="display: none;">Completing authentication...</p>
<div id="success" class="success"></div>
<p id="message-for-opener" style="display: none;">Authentication successful! You can close this window.</p>
<div id="error" class="error"></div>
</div>
<script>
<!--#include virtual="/config.js" -->
(function() {
/**
* Decodes a JWT token and returns the payload
* @param {string} token - The JWT token to decode
* @returns {object} The decoded payload
*/
function decodeJWT(token) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
} catch (error) {
throw new Error('Failed to decode JWT: ' + error.message);
}
}
/**
* Validates that the nonce in the ID token matches the stored nonce
* @param {string} idToken - The ID token to validate
* @param {string} expectedNonce - The nonce stored in sessionStorage
*/
function validateNonce(idToken, expectedNonce) {
const payload = decodeJWT(idToken);
if (!payload.nonce) {
throw new Error('ID token does not contain a nonce claim');
}
if (payload.nonce !== expectedNonce) {
throw new Error('Nonce validation failed: ID token nonce does not match stored nonce');
}
}
async function handleAuthorizationCode() {
try {
// Parse query parameters (Authorization Code Flow uses query params, not hash)
const urlParams = new URLSearchParams(window.location.search);
// Get state parameter first to check for refresh token
const stateParam = urlParams.get('state');
if (!stateParam) {
throw new Error('No state parameter received');
}
const state = JSON.parse(decodeURIComponent(stateParam));
// Get SSO configuration from config
const { sso } = config;
if (!sso || !sso.tokenService || !sso.clientId) {
throw new Error('Missing SSO configuration (tokenService or clientId)');
}
// Check if we have a refresh token in state - use it to refresh tokens
if (state.refreshToken) {
console.log('Using refresh token to get new tokens');
// Show spinner while refreshing tokens
document.getElementById('spinner').style.display = 'block';
document.getElementById('spinner-msg').style.display = 'block';
// Use refresh token to get new tokens
const tokenResponse = await fetch(`https://${sso.tokenService}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: state.refreshToken,
client_id: sso.clientId
})
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json().catch(() => ({}));
throw new Error(errorData.error_description || errorData.error || 'Token refresh failed');
}
const tokens = await tokenResponse.json();
const accessToken = tokens.access_token;
const idToken = tokens.id_token;
const refreshToken = tokens.refresh_token || state.refreshToken; // Use new refresh token if provided, otherwise keep old one
if (!accessToken || !idToken) {
throw new Error('Missing tokens in refresh response');
}
// Validate nonce if available in sessionStorage (optional for refresh flows)
const refreshNonce = sessionStorage.getItem('oauth_nonce');
if (refreshNonce) {
try {
validateNonce(idToken, refreshNonce);
} catch (nonceError) {
console.warn('Nonce validation failed during refresh:', nonceError.message);
// Don't fail the refresh flow if nonce validation fails
}
}
const message = {
type: 'oauth-success',
accessToken: accessToken,
idToken: idToken,
refreshToken: refreshToken
};
// Send message to parent window
if (window.opener) {
document.getElementById('spinner').style.display = 'none';
document.getElementById('spinner-msg').style.display = 'none';
document.getElementById('success').style.display = 'block';
document.getElementById('message-for-opener').style.display = 'block';
window.opener.postMessage(message, window.location.origin);
} else if (window.parent && window.parent !== window) {
console.log('Sending refreshed tokens to parent (iframe)');
window.parent.postMessage(message, window.location.origin);
} else {
// Standalone page - redirect to room
const tenant = state.tenant || '';
const roomSafe = state.roomSafe || state.room || '';
if (!roomSafe) {
throw new Error('No room specified in state');
}
const protocol = window.location.protocol;
const host = window.location.host;
const path = tenant ? `/${tenant}/${roomSafe}` : `/${roomSafe}`;
const hashParams = new URLSearchParams();
for (const [key, value] of Object.entries(state)) {
if (key.startsWith('config.')) {
hashParams.append(key, String(value));
}
}
hashParams.append('jwt', `"${accessToken}"`);
const redirectUrl = `${protocol}//${host}${path}#${hashParams.toString()}`;
window.location.href = redirectUrl;
}
return; // Exit after handling refresh token
}
// No refresh token in state - proceed with authorization code flow
// Check for OAuth errors
const error = urlParams.get('error');
if (error) {
const errorDescription = urlParams.get('error_description');
console.error('OAuth error:', error, errorDescription);
if (window.opener) {
window.opener.postMessage({
type: 'oauth-error',
error: errorDescription || error
}, window.location.origin);
} else if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'oauth-error',
error: errorDescription || error
}, window.location.origin);
}
return;
}
// Get authorization code
const code = urlParams.get('code');
if (!code) {
throw new Error('No authorization code received');
}
// Retrieve code_verifier and oauth_nonce from sessionStorage (set during login initiation)
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('No PKCE code verifier found in session');
}
const oauthNonce = sessionStorage.getItem('oauth_nonce');
if (!oauthNonce) {
throw new Error('No OAuth nonce found in session');
}
// Show spinner while exchanging code for tokens
document.getElementById('spinner').style.display = 'block';
document.getElementById('spinner-msg').style.display = 'block';
// Exchange authorization code for tokens
const tokenResponse = await fetch(`https://${sso.tokenService}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: window.location.origin + window.location.pathname,
client_id: sso.clientId,
code_verifier: codeVerifier
})
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json().catch(() => ({}));
throw new Error(errorData.error_description || errorData.error || 'Token exchange failed');
}
const tokens = await tokenResponse.json();
const accessToken = tokens.access_token;
const idToken = tokens.id_token;
const refreshToken = tokens.refresh_token;
if (!accessToken || !idToken) {
throw new Error('Missing tokens in response');
}
// Validate nonce in ID token
validateNonce(idToken, oauthNonce);
const message = {
type: 'oauth-success',
accessToken: accessToken,
idToken: idToken,
refreshToken: refreshToken
};
// Send message to parent window
if (window.opener) {
// Opened via window.open() - popup
document.getElementById('spinner').style.display = 'none';
document.getElementById('spinner-msg').style.display = 'none';
document.getElementById('success').style.display = 'block';
document.getElementById('message-for-opener').style.display = 'block';
window.opener.postMessage(message, window.location.origin);
} else if (window.parent && window.parent !== window) {
// Loaded in iframe
console.log('Sending message to parent (iframe)');
window.parent.postMessage(message, window.location.origin);
} else {
// Standalone page - redirect to room
// Extract required values
const tenant = state.tenant || '';
const roomSafe = state.roomSafe || state.room || '';
if (!roomSafe) {
throw new Error('No room specified in state');
}
// Build the new URL
const protocol = window.location.protocol;
const host = window.location.host;
// Construct path: /tenant/roomSafe
const path = tenant ? `/${tenant}/${roomSafe}` : `/${roomSafe}`;
// Build hash parameters - only config.* values and jwt
const hashParams = new URLSearchParams();
// Add only config.* parameters from state
for (const [key, value] of Object.entries(state)) {
if (key.startsWith('config.')) {
hashParams.append(key, String(value));
}
}
// Add access_token as jwt with quotes
hashParams.append('jwt', `"${accessToken}"`);
// Build final URL
const redirectUrl = `${protocol}//${host}${path}#${hashParams.toString()}`;
console.log('Redirecting to:', redirectUrl);
// Redirect
window.location.href = redirectUrl;
}
} catch (error) {
console.error('SSO Error:', error);
const errorMessage = {
type: 'oauth-error',
error: error.message
};
if (window.opener) {
window.opener.postMessage(errorMessage, window.location.origin);
} else if (window.parent && window.parent !== window) {
window.parent.postMessage(errorMessage, window.location.origin);
} else {
document.getElementById('error').textContent = `Error: ${error.message}`;
document.getElementById('error').style.display = 'block';
document.getElementById('spinner').style.display = 'none';
document.getElementById('spinner-msg').style.display = 'none';
}
}
}
// Start authentication flow
handleAuthorizationCode();
})();
</script>
</body>
</html>
Loading…
Cancel
Save