coveo-search-ui
Version:
Coveo JavaScript Search Framework
376 lines (313 loc) • 13.6 kB
text/typescript
import { Component } from '../Base/Component';
import { ComponentOptions } from '../Base/ComponentOptions';
import { Assert } from '../../misc/Assert';
import { QueryEvents, IBuildingCallOptionsEventArgs, IQueryErrorEventArgs } from '../../events/QueryEvents';
import { IComponentBindings } from '../Base/ComponentBindings';
import { IInitializationEventArgs, InitializationEvents } from '../../events/InitializationEvents';
import { SettingsEvents } from '../../events/SettingsEvents';
import { ISettingsPopulateMenuArgs } from '../Settings/Settings';
import { DomUtils } from '../../utils/DomUtils';
import { $$ } from '../../utils/Dom';
import { Initialization } from '../Base/Initialization';
import { l } from '../../strings/Strings';
import { ModalBox } from '../../ExternalModulesShim';
import { MissingAuthenticationError } from '../../rest/MissingAuthenticationError';
import * as _ from 'underscore';
import { exportGlobally } from '../../GlobalExports';
import 'styling/_AuthenticationProvider';
import { SVGIcons } from '../../utils/SVGIcons';
import { HashUtils } from '../../utils/HashUtils';
import { QUERY_STATE_ATTRIBUTES } from '../../models/QueryStateModel';
import { SafeLocalStorage } from '../../utils/LocalStorageUtils';
const handshakeTokenParamName = 'handshake_token';
export interface IAuthenticationProviderOptions {
name?: string;
caption?: string;
useIFrame?: boolean;
showIFrame?: boolean;
}
/**
* The `AuthenticationProvider` component makes it possible to execute queries with an identity that the end user
* can obtain using an authentication provider configured on the Coveo REST Search API
* (see [Claims Authentication](https://docs.coveo.com/en/113/)).
*
* When necessary, this component handles redirecting the browser to the address that starts the authentication process.
*
* You can use the `data-tab` attribute to enable the `AuthenticationProvider` component only for the tabs of your
* search interface that require authentication (see the [`Tab`]{@link Tab} component).
*/
export class AuthenticationProvider extends Component {
static ID = 'AuthenticationProvider';
static handshakeInProgress = false;
static doExport = () => {
exportGlobally({
AuthenticationProvider: AuthenticationProvider
});
};
/**
* The options for the component.
* @componentOptions
*/
static options: IAuthenticationProviderOptions = {
/**
* Specifies the name of the authentication provider.
*
* See [SAML Authentication](https://docs.coveo.com/en/91/).
*/
name: ComponentOptions.buildStringOption(),
/**
* Specifies the display name of the authentication provider. This is the name that you want to appear in the user
* interface when the end user is logging in.
*
* Default value is the [`name`]{@link AuthenticationProvider.options.name} option value.
*/
caption: ComponentOptions.buildStringOption({ postProcessing: (value, options) => value || options.name }),
/**
* Specifies whether to use an `<iframe>` to host the chain of redirection that make up the authentication
* process.
*
* Using an `<iframe>` prevents leaving the search page as part of the authentication process. However, some login
* providers refuse to load in an `<iframe>`.
*
* Default value is `false`.
*/
useIFrame: ComponentOptions.buildBooleanOption({
defaultValue: false,
alias: ['useIframe']
}),
/**
* If the [`useIFrame`]{@link AuthenticationProvider.options.useIFrame} option is `true`, specifies whether to make
* the authentication `<iframe>` visible to the user (inside a popup).
*
* When the underlying authentication provider requires no user interaction (for example, when a user authenticates
* using Windows authentication along with SharePoint claims), setting this option to `false` reduces the visual
* impact of the authentication process.
*
* Default value is `true`.
*/
showIFrame: ComponentOptions.buildBooleanOption({
defaultValue: true,
alias: ['showIframe'],
depend: 'useIFrame'
})
};
private handlers: ((...args: any[]) => void)[];
private redirectCount: number;
private storage = new SafeLocalStorage();
/**
* Creates a new `AuthenticationProvider` component.
* @param element The HTMLElement on which to instantiate the component.
* @param options The options for the `AuthenticationProvider` component.
* @param bindings The bindings that the component requires to function normally. If not set, these will be
* automatically resolved (with a slower execution time).
*/
constructor(
public element: HTMLElement,
public options: IAuthenticationProviderOptions = {},
bindings?: IComponentBindings,
public _window?: Window
) {
super(element, AuthenticationProvider.ID, bindings);
this.options = ComponentOptions.initComponentOptions(element, AuthenticationProvider, options);
Assert.exists(this.options.name);
this.handlers = [];
this._window = this._window || window;
this.redirectCount = 0;
this.bind.onRootElement(QueryEvents.buildingCallOptions, this.handleBuildingCallOptions);
this.bind.onRootElement(QueryEvents.queryError, this.handleQueryError);
this.bind.onRootElement(InitializationEvents.nuke, this.handleNuke);
this.bind.onRootElement(InitializationEvents.afterComponentsInitialization, (args: IInitializationEventArgs) =>
this.onAfterComponentsInitialization(args)
);
this.bind.onRootElement(SettingsEvents.settingsPopulateMenu, (args: ISettingsPopulateMenuArgs) => {
args.menuData.push({
text: l('Reauthenticate', this.options.caption),
className: 'coveo-authentication-provider',
onOpen: () => this.authenticateWithProvider(),
svgIcon: SVGIcons.icons.dropdownAuthenticate,
svgIconClassName: 'coveo-authentication-provider-svg'
});
});
}
private getHandshakeTokenFromUrl() {
const hash = this.getHashAfterAdjustingForAngular();
const token = HashUtils.getValue(handshakeTokenParamName, hash);
return typeof token === 'string' ? token : '';
}
private getHashAfterAdjustingForAngular() {
const hash = HashUtils.getHash();
return this.isAngularHash ? `#${hash.slice(2)}` : hash;
}
private get isAngularHash() {
const rawHash = HashUtils.getHash();
return rawHash.indexOf('#/') === 0;
}
private onAfterComponentsInitialization(args: IInitializationEventArgs) {
if (AuthenticationProvider.handshakeInProgress) {
const promise = this.waitForHandshakeToFinish().then(() => this.loadAccessTokenFromStorage());
args.defer.push(promise);
return;
}
const handshakeToken = this.getHandshakeTokenFromUrl();
if (!handshakeToken) {
return this.loadAccessTokenFromStorage();
}
if (!this.shouldExchangeHandshakeToken) {
return;
}
this.enableHandshakeInProgressFlag();
const promise = this.exchangeHandshakeToken(handshakeToken)
.then(token => this.storeAccessToken(token))
.then(() => this.removeHandshakeTokenFromUrl())
.then(() => this.loadAccessTokenFromStorage())
.catch(e => this.logger.error(e))
.finally(() => this.disableHandshakeInProgressFlag());
args.defer.push(promise);
}
private get shouldExchangeHandshakeToken() {
const tabId = $$(this.element).getAttribute('data-tab');
const hash = HashUtils.getHash();
const activeTabId = HashUtils.getValue(QUERY_STATE_ATTRIBUTES.T, hash);
return !tabId || tabId === activeTabId;
}
private exchangeHandshakeToken(handshakeToken: string) {
const accessToken = this.getAccessTokenFromStorage();
const options = accessToken ? { handshakeToken, accessToken } : { handshakeToken };
return this.queryController.getEndpoint().exchangeHandshakeToken(options);
}
private storeAccessToken(accessToken: string) {
this.storage.setItem(this.accessTokenStorageKey, accessToken);
}
private get accessTokenStorageKey() {
const { organizationId } = this.queryController.getEndpoint().options.queryStringArguments;
return `coveo-auth-provider-access-token-${organizationId}`;
}
private waitForHandshakeToFinish() {
return new Promise(resolve => {
const interval = setInterval(() => {
if (AuthenticationProvider.handshakeInProgress) {
return;
}
clearInterval(interval);
resolve();
}, 100);
});
}
private enableHandshakeInProgressFlag() {
AuthenticationProvider.handshakeInProgress = true;
}
private disableHandshakeInProgressFlag() {
AuthenticationProvider.handshakeInProgress = false;
}
private removeHandshakeTokenFromUrl() {
const delimiter = `&`;
const hash = this.getHashAfterAdjustingForAngular();
const token = this.getHandshakeTokenFromUrl();
const handshakeEntry = `${handshakeTokenParamName}=${token}`;
const entries = hash.substr(1).split(delimiter);
const newHash = entries.filter(param => param !== handshakeEntry).join(delimiter);
const adjustedHash = this.isAngularHash ? `/${newHash}` : newHash;
this._window.history.replaceState(null, '', `#${adjustedHash}`);
}
private loadAccessTokenFromStorage() {
const token = this.getAccessTokenFromStorage();
token && this.queryController.getEndpoint().accessToken.updateToken(token);
}
private getAccessTokenFromStorage() {
return this.storage.getItem(this.accessTokenStorageKey);
}
private handleBuildingCallOptions(args: IBuildingCallOptionsEventArgs) {
args.options.authentication.push(this.options.name);
}
private handleQueryError(args: IQueryErrorEventArgs) {
const token = this.getAccessTokenFromStorage();
const shouldClearToken = this.shouldClearTokenFollowingErrorEvent(args);
if (token && shouldClearToken) {
this.storage.removeItem(this.accessTokenStorageKey);
this._window.location.reload();
return;
}
let missingAuthError = <MissingAuthenticationError>args.error;
if (
missingAuthError.isMissingAuthentication &&
missingAuthError.provider === this.options.name &&
this.redirectCount < 2 &&
this.redirectCount !== -1
) {
++this.redirectCount;
this.authenticateWithProvider();
} else {
this.logger.error('The AuthenticationProvider is in a redirect loop. This may be due to a back-end configuration problem.');
this.redirectCount = -1;
}
}
private shouldClearTokenFollowingErrorEvent(args: IQueryErrorEventArgs) {
const exceptions = ['InvalidTokenException', 'ExpiredTokenException', 'InvalidAuthenticationProviderException'];
const error = args.error.name;
return exceptions.indexOf(error) !== -1;
}
private authenticateWithProvider() {
this.options.useIFrame ? this.authenticateWithIFrame() : this.redirectToAuthenticationProvider();
}
private redirectToAuthenticationProvider() {
this.logger.info(`Redirecting to authentication provider ${this.options.name}`);
this._window.location.href = this.getAuthenticationProviderUriForRedirect();
}
private authenticateWithIFrame() {
this.logger.info(`Using iframe to retrieve authentication for provider ${this.options.name}`);
let iframe = $$('iframe', {
src: this.getAuthenticationProviderUriForIFrame()
}).el;
let modalbox: Coveo.ModalBox.ModalBox;
modalbox = this.options.showIFrame ? this.createPopupForVisibleIFrame(iframe) : this.createPopupForWaitMessage(iframe);
let handler = this.createHandler(modalbox, iframe);
$$(<any>this._window).one('message', handler);
this.handlers.push(handler);
}
private createHandler(modalbox: Coveo.ModalBox.ModalBox, iframe: HTMLElement): () => void {
return () => {
$$(iframe).detach();
this.logger.info(`Got authentication for provider ${this.options.name}; retrying query.`);
modalbox.close();
this.queryController.executeQuery();
};
}
private handleNuke() {
_.each(this.handlers, handler => $$(<any>this._window).off('message', handler));
}
private createPopupForWaitMessage(iframe: HTMLElement): Coveo.ModalBox.ModalBox {
let popup = $$(
'div',
{
className: 'coveo-waiting-for-authentication-popup'
},
DomUtils.getBasicLoadingAnimation()
).el;
$$(iframe).hide();
document.body.appendChild(iframe);
ModalBox.open(popup, {
title: l('Authenticating', this.options.caption),
sizeMod: 'small',
body: this.searchInterface.options.modalContainer
});
return ModalBox;
}
private createPopupForVisibleIFrame(iframe: HTMLElement): Coveo.ModalBox.ModalBox {
$$(iframe).addClass('coveo-authentication-iframe');
let popup = $$('div', {}, iframe).el;
ModalBox.open(popup, {
title: l('Authenticating', this.options.caption),
className: 'coveo-authentication-popup',
sizeMod: 'big',
body: this.searchInterface.options.modalContainer
});
return ModalBox;
}
private getAuthenticationProviderUriForRedirect(): string {
return this.queryController.getEndpoint().getAuthenticationProviderUri(this.options.name, this._window.location.href, undefined);
}
private getAuthenticationProviderUriForIFrame(): string {
return this.queryController.getEndpoint().getAuthenticationProviderUri(this.options.name, undefined, 'success');
}
}
Initialization.registerAutoCreateComponent(AuthenticationProvider);