360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import { maybeRedirectToWelcomePage } from '../app/actions.web';
|
|
import { IStore } from '../app/types';
|
|
import { connect } from '../base/connection/actions';
|
|
import { openDialog } from '../base/dialog/actions';
|
|
import { setJWT } from '../base/jwt/actions';
|
|
import { browser } from '../base/lib-jitsi-meet';
|
|
import { hideNotification, showErrorNotification, showNotification } from '../notifications/actions';
|
|
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
|
|
|
|
import { CANCEL_LOGIN } from './actionTypes';
|
|
import LoginQuestionDialog from './components/web/LoginQuestionDialog';
|
|
import { isTokenAuthInline } from './functions.any';
|
|
import logger from './logger';
|
|
|
|
export * from './actions.any';
|
|
|
|
const PROMPT_POPUP_NOTIFICATION_ID = 'PROMPT_POPUP_NOTIFICATION_ID';
|
|
|
|
/**
|
|
* Custom error that includes a recovery callback.
|
|
*/
|
|
class PopupBlockedError extends Error {
|
|
constructor(
|
|
message: string = 'Popup was blocked by browser',
|
|
public readonly retry?: () => Window | null
|
|
) {
|
|
super(message);
|
|
this.name = 'PopupBlockedError';
|
|
Error.captureStackTrace(this, PopupBlockedError);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom error thrown when a pending inline login popup is closed
|
|
* programmatically (e.g. the participant is admitted to the conference while
|
|
* the popup is still open), as opposed to failing to authenticate.
|
|
*/
|
|
class LoginCancelledError extends Error {
|
|
constructor(message: string = 'Login cancelled') {
|
|
super(message);
|
|
this.name = 'LoginCancelledError';
|
|
Error.captureStackTrace(this, LoginCancelledError);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Holds a function that closes the currently open inline login popup (if any)
|
|
* and settles its pending promise. It is set while a popup is open and cleared
|
|
* once the popup is closed, so it can be used to dismiss the popup when it is no
|
|
* longer needed (e.g. when the participant is admitted to the conference).
|
|
*/
|
|
let closeActiveLoginPopup: (() => void) | undefined;
|
|
|
|
/**
|
|
* Cancels {@ink LoginDialog}.
|
|
*
|
|
* @returns {{
|
|
* type: CANCEL_LOGIN
|
|
* }}
|
|
*/
|
|
export function cancelLogin() {
|
|
return {
|
|
type: CANCEL_LOGIN
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cancels authentication, closes {@link WaitForOwnerDialog}
|
|
* and navigates back to the welcome page only in the case of authentication required error.
|
|
* We can be showing the dialog while lobby is enabled and participant is still waiting there and hiding this dialog
|
|
* should do nothing.
|
|
*
|
|
* @returns {Function}
|
|
*/
|
|
export function cancelWaitForOwner() {
|
|
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
|
const { authRequired } = getState()['features/base/conference'];
|
|
|
|
authRequired && dispatch(maybeRedirectToWelcomePage());
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Redirect to the default location (e.g. Welcome page).
|
|
*
|
|
* @returns {Function}
|
|
*/
|
|
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.
|
|
* @param {Window|undefined} popup - Authentication service URL.
|
|
* @returns {Promise<any>} A promise that resolves with the authentication
|
|
* result or rejects with an error.
|
|
*/
|
|
export function loginWithPopup(tokenAuthServiceUrl: string, popup?: Window | null): Promise<any> {
|
|
return new Promise<any>((resolve, reject) => {
|
|
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 openPopup = () => {
|
|
// Open popup
|
|
const width = 500;
|
|
const height = 600;
|
|
const left = window.screen.width / 2 - width / 2;
|
|
const top = window.screen.height / 2 - height / 2;
|
|
|
|
return window.open(
|
|
`${tokenAuthServiceUrl}${nonceParam}`,
|
|
`Auth-${Date.now()}`,
|
|
`width=${width},height=${height},left=${left},top=${top}`
|
|
);
|
|
};
|
|
|
|
if (!popup) {
|
|
popup = openPopup();
|
|
}
|
|
|
|
if (!popup) {
|
|
// adds a callback to the error that can be used to directly retry where window.open will be executed
|
|
// on the user click without any promises(dispatches) incoved
|
|
reject(new PopupBlockedError('Popup blocked', () => {
|
|
return openPopup();
|
|
}));
|
|
|
|
return;
|
|
}
|
|
|
|
let closedPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
|
|
const cleanup = (handler: (event: MessageEvent) => void) => {
|
|
window.removeEventListener('message', handler);
|
|
clearInterval(closedPollInterval);
|
|
popup?.close();
|
|
closeActiveLoginPopup = undefined;
|
|
|
|
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);
|
|
|
|
// Expose a way to close this popup externally, so it can be dismissed
|
|
// when it is no longer needed (e.g. the participant was admitted to the
|
|
// conference while the popup was still open).
|
|
closeActiveLoginPopup = () => {
|
|
cleanup(handler);
|
|
reject(new LoginCancelledError());
|
|
};
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Closes the currently open inline login popup (if any) and settles its pending
|
|
* promise. Used to dismiss the popup when it is no longer needed, e.g. when the
|
|
* participant is admitted to the conference while the popup is still open.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
export function closeLoginPopup() {
|
|
closeActiveLoginPopup?.();
|
|
}
|
|
|
|
/**
|
|
* Opens token auth URL page.
|
|
*
|
|
* @param {string} tokenAuthServiceUrl - Authentication service URL.
|
|
*
|
|
* @returns {Function}
|
|
*/
|
|
export function openTokenAuthUrl(tokenAuthServiceUrl: string): any {
|
|
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
|
const redirect = () => {
|
|
if (browser.isElectron()) {
|
|
window.open(tokenAuthServiceUrl, '_blank');
|
|
} else {
|
|
window.location.href = tokenAuthServiceUrl;
|
|
}
|
|
};
|
|
|
|
if (!browser.isElectron() && isTokenAuthInline(getState()['features/base/config'])) {
|
|
const login = (popup?: Window | null) => {
|
|
return loginWithPopup(tokenAuthServiceUrl, popup)
|
|
.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'];
|
|
|
|
if (connection) {
|
|
connection.refreshToken(token).then(
|
|
() => {
|
|
const { membersOnly } = getState()['features/base/conference'];
|
|
|
|
membersOnly?.join();
|
|
})
|
|
.catch((err: any) => {
|
|
dispatch(setJWT());
|
|
logger.error(err);
|
|
});
|
|
} else {
|
|
dispatch(connect());
|
|
}
|
|
});
|
|
};
|
|
|
|
login().catch(err => {
|
|
if (err instanceof PopupBlockedError) {
|
|
dispatch(showNotification({
|
|
titleKey: 'dialog.loginPopupBlocked',
|
|
uid: PROMPT_POPUP_NOTIFICATION_ID,
|
|
customActionNameKey: [ 'dialog.retry' ],
|
|
customActionHandler: [ () => {
|
|
dispatch(hideNotification(PROMPT_POPUP_NOTIFICATION_ID));
|
|
|
|
// the window.open will be executed directly from the onClick handler of the notification action button
|
|
login(err.retry ? err.retry() : undefined)
|
|
.catch(logger.error);
|
|
} ],
|
|
appearance: NOTIFICATION_TYPE.ERROR
|
|
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
|
|
logger.error(err);
|
|
} else if (err instanceof LoginCancelledError) {
|
|
// The popup was closed programmatically (e.g. we were
|
|
// admitted to the conference while it was still open); this
|
|
// is not an authentication failure, so nothing to report.
|
|
logger.info('Inline login popup closed:', err.message);
|
|
} else {
|
|
// let's add expand that will show the error message in the notification
|
|
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, {
|
|
handler: () => {
|
|
// Give time for the dialog to close.
|
|
setTimeout(() => redirect(), 500);
|
|
}
|
|
}));
|
|
} else {
|
|
redirect();
|
|
}
|
|
};
|
|
}
|