blob: 26799eec862b5ba92a82e2075ed80728daabb10b [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// <include src="post_message_channel.js">
// <include src="webview_event_manager.js">
// <include src="saml_password_attributes.js">
// clang-format off
// #import {Channel} from './channel.m.js';
// #import {PostMessageChannel} from './post_message_channel.m.js';
// #import {WebviewEventManager} from './webview_event_manager.m.js';
// #import {NativeEventTarget as EventTarget} from 'chrome://resources/js/cr/event_target.m.js'
// #import {PasswordAttributes, readPasswordAttributes} from './saml_password_attributes.m.js';
// clang-format on
/**
* @fileoverview Saml support for webview based auth.
*/
cr.define('cr.login', function() {
/* #ignore */ 'use strict';
/**
* The lowest version of the credentials passing API supported.
* @type {number}
*/
const MIN_API_VERSION_VERSION = 1;
/**
* The highest version of the credentials passing API supported.
* @type {number}
*/
const MAX_API_VERSION_VERSION = 1;
/**
* The key types supported by the credentials passing API.
* @type {Array} Array of strings.
*/
const API_KEY_TYPES = [
'KEY_TYPE_PASSWORD_PLAIN',
];
/** @const */
const SAML_HEADER = 'google-accounts-saml';
/** @const */
const SAML_VERIFIED_ACCESS_CHALLENGE_HEADER = 'x-verified-access-challenge';
/** @const */
const SAML_VERIFIED_ACCESS_RESPONSE_HEADER =
'x-verified-access-challenge-response';
/** @const */
const BEGIN_CERTIFICATE = '-----BEGIN CERTIFICATE-----';
/** @const */
const END_CERTIFICATE = '-----END CERTIFICATE-----';
/** @const */
const injectedScriptName = 'samlInjected';
/**
* The script to inject into webview and its sub frames.
* @type {string}
*/
const injectedJs = 'webview_saml_injected.js';
/**
* @typedef {{
* method: string,
* requestedVersion: number,
* keyType: string,
* token: string,
* passwordBytes: string
* }}
*/
let ApiCallMessageCall;
/**
* @typedef {{
* name: string,
* call: ApiCallMessageCall
* }}
*/
let ApiCallMessage;
/**
* Details about the request.
* @typedef {{
* method: string,
* requestBody: Object,
* url: string
* }}
* @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onBeforeRequest#details
*/
/* #export */ let OnBeforeRequestDetails;
/**
* Details of the request.
* @typedef {{
* responseHeaders: Array<HttpHeader>,
* statusCode: number,
* url: string,
* }}
* @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onHeadersReceived#details
*/
/* #export */ let OnHeadersReceivedDetails;
/**
* Creates a new URL by striping all query parameters.
* @param {string} url The original URL.
* @return {string} The new URL with all query parameters stripped.
*/
function stripParams(url) {
return url.substring(0, url.indexOf('?')) || url;
}
/**
* A handler to provide saml support for the given webview that hosts the
* auth IdP pages.
*/
/* #export */ class SamlHandler extends cr.EventTarget {
/**
* @param {!WebView} webview
* @param {boolean} startsOnSamlPage - whether initial URL is already SAML
* page
* */
constructor(webview, startsOnSamlPage) {
super();
/**
* Device attestation flow stages.
* @enum {number}
* @private
*/
SamlHandler.DeviceAttestationStage = {
// No device attestation in progress.
NONE: 1,
// A Redirect was received with a HTTP header that contained a device
// attestation challenge.
CHALLENGE_RECEIVED: 2,
// The Redirect has been canceled and a device attestation challenge
// response is being computed.
ORIGINAL_REDIRECT_CANCELED: 3,
// The device attestation challenge response is available and the
// original Redirect is being followed with the response included in a
// HTTP header.
NAVIGATING_TO_REDIRECT_PAGE: 4,
};
/**
* The webview that serves IdP pages.
* @private {!WebView}
*/
this.webview_ = webview;
/**
* Whether a Saml page is in the webview from the start.
* @private {boolean}
*/
this.startsOnSamlPage_ = startsOnSamlPage;
/**
* Whether a Saml IdP page is display in the webview.
* @private {boolean}
*/
this.isSamlPage_ = this.startsOnSamlPage_;
/**
* Pending Saml IdP page flag that is set when a SAML_HEADER is received
* and is copied to |isSamlPage_| in loadcommit.
* @private {boolean}
*/
this.pendingIsSamlPage_ = this.startsOnSamlPage_;
/**
* The last aborted top level url. It is recorded in loadabort event and
* used to skip injection into Chrome's error page in the following
* loadcommit event.
* @private {?string}
*/
this.abortedTopLevelUrl_ = null;
/**
* Scraped password stored in an id to password field value map.
* @private {!Object<string, string>}
*/
this.passwordStore_ = {};
/**
* Whether Saml API is initialized.
* @private {boolean}
*/
this.apiInitialized_ = false;
/**
* Saml API version to use.
* @private {number}
*/
this.apiVersion_ = 0;
/**
* Saml API tokens received.
* @private {!Object}
*/
this.apiTokenStore_ = {};
/**
* Saml API confirmation token. Set by last 'confirm' call.
* @private {?string}
*/
this.confirmToken_ = null;
/**
* Saml API password bytes set by last 'add' call. Needed to not break
* existing behavior.
* @private {?string}
*/
this.lastApiPasswordBytes_ = null;
/**
* Whether to abort the authentication flow and show an error message
* when content served over an unencrypted connection is detected.
* @type {boolean}
*/
this.blockInsecureContent = false;
/**
* Whether to attempt to extract password attributes from the SAMLResponse
* XML. See saml_password_attributes.js
* @type {boolean}
*/
this.extractSamlPasswordAttributes = false;
/**
* Current stage of device attestation flow.
* @private {!SamlHandler.DeviceAttestationStage}
*/
this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;
/**
* Challenge from IdP to perform device attestation.
* @private {?string}
*/
this.verifiedAccessChallenge_ = null;
/**
* Response for a device attestation challenge.
* @private {?string}
*/
this.verifiedAccessChallengeResponse_ = null;
/**
* Certificate that were extracted from the SAMLResponse.
* @public {?string}
*/
this.x509certificate = null;
/**
* The password-attributes that were extracted from the SAMLResponse, if
* any. (Doesn't contain the password itself).
* @private {!PasswordAttributes}
*/
this.passwordAttributes_ =
samlPasswordAttributes.PasswordAttributes.EMPTY;
this.webviewEventManager_ = WebviewEventManager.create();
this.webviewEventManager_.addEventListener(
this.webview_, 'contentload', this.onContentLoad_.bind(this));
this.webviewEventManager_.addEventListener(
this.webview_, 'loadabort', this.onLoadAbort_.bind(this));
this.webviewEventManager_.addEventListener(
this.webview_, 'permissionrequest',
this.onPermissionRequest_.bind(this));
this.webviewEventManager_.addWebRequestEventListener(
this.webview_.request.onBeforeRequest,
this.onInsecureRequest.bind(this),
{urls: ['http://*/*', 'file://*/*', 'ftp://*/*']}, ['blocking']);
this.webviewEventManager_.addWebRequestEventListener(
this.webview_.request.onBeforeRequest,
this.onMainFrameWebRequest.bind(this),
{urls: ['http://*/*', 'https://*/*'], types: ['main_frame']},
['requestBody']);
if (!this.startsOnSamlPage_) {
this.webviewEventManager_.addEventListener(
this.webview_, 'loadcommit', this.onLoadCommit_.bind(this));
this.webviewEventManager_.addWebRequestEventListener(
this.webview_.request.onBeforeRequest,
this.onBeforeRequest_.bind(this),
{urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
['blocking']);
this.webviewEventManager_.addWebRequestEventListener(
this.webview_.request.onBeforeSendHeaders,
this.onBeforeSendHeaders_.bind(this),
{urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
['blocking', 'requestHeaders']);
this.webviewEventManager_.addWebRequestEventListener(
this.webview_.request.onHeadersReceived,
this.onHeadersReceived_.bind(this),
{urls: ['<all_urls>'], types: ['main_frame', 'xmlhttprequest']},
['blocking', 'responseHeaders']);
}
this.webview_.addContentScripts([{
name: injectedScriptName,
matches: ['http://*/*', 'https://*/*'],
js: {files: [injectedJs]},
all_frames: true,
run_at: 'document_start'
}]);
PostMessageChannel.runAsDaemon(this.onConnected_.bind(this));
}
/**
* Whether Saml API is used during auth.
* @return {boolean}
*/
get samlApiUsed() {
return !!this.lastApiPasswordBytes_;
}
/**
* Returns the Saml API password bytes.
* @return {?string}
*/
get apiPasswordBytes() {
if (this.confirmToken_ != null &&
typeof (this.apiTokenStore_[this.confirmToken_]) == 'object' &&
typeof (this.apiTokenStore_[this.confirmToken_]['passwordBytes']) ==
'string') {
return this.apiTokenStore_[this.confirmToken_]['passwordBytes'];
}
return this.lastApiPasswordBytes_;
}
/**
* Returns the first scraped password if any, or an empty string otherwise.
* @return {string}
*/
get firstScrapedPassword() {
const scraped = this.getConsolidatedScrapedPasswords_();
return scraped.length ? scraped[0] : '';
}
/**
* Returns the number of scraped passwords.
* @return {number}
*/
get scrapedPasswordCount() {
return this.getConsolidatedScrapedPasswords_().length;
}
/**
* Gets the list of passwords which have matching passwordProperty and
* are scraped exactly |times| times.
* @return {Array<string>}
*/
getPasswordsWithPropertyScrapedTimes(times, passwordProperty) {
const passwords = {};
for (const property in this.passwordStore_) {
if (passwordProperty && !property.match(passwordProperty)) {
continue;
}
const key = this.passwordStore_[property];
passwords[key] = (passwords[key] + 1) || 1;
}
return Object.keys(passwords).filter(key => passwords[key] == times);
}
/**
* Gets the de-duped scraped passwords.
* @return {Array<string>}
* @private
*/
getConsolidatedScrapedPasswords_() {
const passwords = {};
for (const property in this.passwordStore_) {
passwords[this.passwordStore_[property]] = true;
}
return Object.keys(passwords);
}
/**
* Gets the password attributes extracted from SAML Response.
* @return {Object}
*/
get passwordAttributes() {
return this.passwordAttributes_;
}
/**
* Sets the startsOnSamlPage attribute of the SAML handler.
* @param {boolean} value
*/
set startsOnSamlPage(value) {
this.startsOnSamlPage_ = value;
this.reset();
}
/**
* Removes the injected content script and unbinds all listeners from the
* webview passed to the constructor. This SAMLHandler will be unusable
* after this function returns.
*/
unbindFromWebview() {
this.webview_.removeContentScripts([injectedScriptName]);
this.webviewEventManager_.removeAllListeners();
}
/**
* Resets all auth states
*/
reset() {
this.isSamlPage_ = this.startsOnSamlPage_;
this.pendingIsSamlPage_ = this.startsOnSamlPage_;
this.passwordStore_ = {};
this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;
this.verifiedAccessChallenge_ = null;
this.verifiedAccessChallengeResponse_ = null;
this.apiInitialized_ = false;
this.apiVersion_ = 0;
this.apiTokenStore_ = {};
this.confirmToken_ = null;
this.lastApiPasswordBytes_ = null;
this.passwordAttributes_ =
samlPasswordAttributes.PasswordAttributes.EMPTY;
this.x509certificate = null;
}
/**
* Check whether the given |password| is in the scraped passwords.
* @return {boolean} True if the |password| is found.
*/
verifyConfirmedPassword(password) {
return this.getConsolidatedScrapedPasswords_().indexOf(password) >= 0;
}
/**
* Check that last navigation was aborted intentionally. It will be
* continued later, so the abort event can be ignored.
* @return {boolean}
*/
isIntentionalAbort() {
return this.deviceAttestationStage_ ==
SamlHandler.DeviceAttestationStage.ORIGINAL_REDIRECT_CANCELED;
}
/**
* Invoked on the webview's contentload event.
* @private
*/
onContentLoad_(e) {
// |this.webview_.contentWindow| may be null after network error screen
// is shown. See crbug.com/770999.
if (this.webview_.contentWindow) {
PostMessageChannel.init(this.webview_.contentWindow);
} else {
console.error('SamlHandler.onContentLoad_: contentWindow is null.');
}
}
/**
* Invoked on the webview's loadabort event.
* @private
*/
onLoadAbort_(e) {
if (this.isIntentionalAbort()) {
return;
}
if (e.isTopLevel) {
this.abortedTopLevelUrl_ = e.url;
}
}
/**
* Invoked on the webview's loadcommit event for both main and sub frames.
* @private
*/
onLoadCommit_(e) {
// Skip this loadcommit if the top level load is just aborted.
if (e.isTopLevel && e.url === this.abortedTopLevelUrl_) {
this.abortedTopLevelUrl_ = null;
return;
}
// Skip for none http/https url.
if (!e.url.startsWith('https://') && !e.url.startsWith('http://')) {
return;
}
this.isSamlPage_ = this.pendingIsSamlPage_;
}
/**
* Handler for webRequest.onBeforeRequest, invoked when content served over
* an unencrypted connection is detected. Determines whether the request
* should be blocked and if so, signals that an error message needs to be
* shown.
* @param {Object} details
* @return {!Object} Decision whether to block the request.
*/
onInsecureRequest(details) {
if (!this.blockInsecureContent) {
return {};
}
const strippedUrl = stripParams(details.url);
this.dispatchEvent(new CustomEvent(
'insecureContentBlocked', {detail: {url: strippedUrl}}));
return {cancel: true};
}
/**
* Set x509certificate in pem-format which is extracted from samlResponse
* and will be used to record SAML provider
* @param {string} samlResponse SAML response which is received from SAML
* page.
* @private
*/
setX509certificate_(samlResponse) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(samlResponse, 'text/xml');
let certificate = xmlDoc.getElementsByTagName('ds:X509Certificate');
if (!certificate || certificate.length == 0) {
// tag 'ds:X509Certificate' doesn't exist
certificate = xmlDoc.getElementsByTagName('X509Certificate');
}
if (certificate && certificate.length > 0 && certificate[0].childNodes &&
certificate[0].childNodes[0] &&
certificate[0].childNodes[0].nodeValue) {
certificate = certificate[0].childNodes[0].nodeValue;
this.x509certificate = BEGIN_CERTIFICATE + '\n' + certificate.trim() +
'\n' + END_CERTIFICATE + '\n';
}
}
/**
* Handler for webRequest.onBeforeRequest that looks for the Base64
* encoded SAMLResponse in the POST-ed formdata sent from the SAML page.
* Non-blocking.
* @param {OnBeforeRequestDetails} details The web-request details.
*/
onMainFrameWebRequest(details) {
if (!this.extractSamlPasswordAttributes) {
return;
}
if (!this.isSamlPage_ || details.method != 'POST') {
return;
}
const formData = details.requestBody.formData;
let samlResponse = (formData && formData.SAMLResponse);
if (!samlResponse) {
samlResponse = new URL(details.url).searchParams.get('SAMLResponse');
}
if (!samlResponse) {
return;
}
try {
// atob means asciiToBinary, which actually means base64Decode:
samlResponse = window.atob(samlResponse);
} catch (decodingError) {
console.warn('SAMLResponse is not Base64 encoded');
return;
}
this.setX509certificate_(samlResponse);
this.passwordAttributes_ =
samlPasswordAttributes.readPasswordAttributes(samlResponse);
}
/**
* Receives a response for a device attestation challenge and navigates to
* saved redirect page.
* @param {string} url Url from canceled redirect.
* @param {{success: boolean, response: string}} challengeResponse Response
* for device attestation challenge. If |success| is true, |response|
* contains challenge response. Otherwise |response| contains empty
* string.
* @private
*/
continueDelayedRedirect_(url, challengeResponse) {
if (this.deviceAttestationStage_ !=
SamlHandler.DeviceAttestationStage.ORIGINAL_REDIRECT_CANCELED) {
console.error(
'SamlHandler.continueDelayedRedirect_: incorrect attestation stage');
return;
}
// Save response only if it is successful.
if (challengeResponse.success) {
this.verifiedAccessChallengeResponse_ = challengeResponse.response;
}
// Navigate to the saved destination from the canceled redirect.
this.deviceAttestationStage_ =
SamlHandler.DeviceAttestationStage.NAVIGATING_TO_REDIRECT_PAGE;
this.webview_.src = url;
}
/**
* Invoked before sending a web request. If a challenge for the remote
* attestation was found in a previous request, cancel the current one. It
* will be continued (reinitiated) later when a challenge response is ready.
* @param {Object} details The web-request details.
* @return {BlockingResponse} Allows the event handler to modify network
* requests.
* @private
*/
onBeforeRequest_(details) {
// Default case without Verified Access.
if (this.deviceAttestationStage_ ==
SamlHandler.DeviceAttestationStage.NONE) {
return {};
}
if (this.deviceAttestationStage_ ==
SamlHandler.DeviceAttestationStage.NAVIGATING_TO_REDIRECT_PAGE) {
return {};
}
if ((this.deviceAttestationStage_ ==
SamlHandler.DeviceAttestationStage.CHALLENGE_RECEIVED) &&
(this.verifiedAccessChallenge_ !== null)) {
// Ask backend to compute response for device attestation challenge.
this.dispatchEvent(new CustomEvent('challengeMachineKeyRequired', {
detail: {
url: details.url,
challenge: this.verifiedAccessChallenge_,
callback: this.continueDelayedRedirect_.bind(this, details.url)
}
}));
this.verifiedAccessChallenge_ = null;
// Cancel redirect by changing destination to javascript:void(0).
// That will produce 'loadabort' event that should be ignored.
this.deviceAttestationStage_ =
SamlHandler.DeviceAttestationStage.ORIGINAL_REDIRECT_CANCELED;
return {redirectUrl: 'javascript:void(0)'};
}
// Reset state in case of unexpected requests during device attestation.
this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;
console.error(
'SamlHandler.onBeforeRequest_: incorrect attestation stage');
return {};
}
/**
* Attaches challenge response during device attestation flow.
* @param {Object} details The web-request details.
* @return {BlockingResponse} Allows the event handler to modify network
* requests.
* @private
*/
onBeforeSendHeaders_(details) {
// Default case without Verified Access.
if (this.deviceAttestationStage_ ==
SamlHandler.DeviceAttestationStage.NONE) {
return {};
}
if (this.deviceAttestationStage_ ==
SamlHandler.DeviceAttestationStage.NAVIGATING_TO_REDIRECT_PAGE) {
// Send extra header only if no error was encountered during challenge
// key procedure.
if (this.verifiedAccessChallengeResponse_ === null) {
this.deviceAttestationStage_ =
SamlHandler.DeviceAttestationStage.NONE;
return {};
}
details.requestHeaders.push({
'name': SAML_VERIFIED_ACCESS_RESPONSE_HEADER,
'value': this.verifiedAccessChallengeResponse_
});
this.verifiedAccessChallengeResponse_ = null;
this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;
return {requestHeaders: details.requestHeaders};
}
// Reset state in case of unexpected navigation during device attestation.
this.deviceAttestationStage_ = SamlHandler.DeviceAttestationStage.NONE;
console.error(
'SamlHandler.onBeforeSendHeaders_: incorrect attestation stage');
return {};
}
/**
* Invoked when headers are received for the main frame.
* @private
*/
onHeadersReceived_(details) {
const headers = details.responseHeaders;
// Check whether GAIA headers indicating the start or end of a SAML
// redirect are present.
for (let i = 0; headers && i < headers.length; ++i) {
const header = headers[i];
const headerName = header.name.toLowerCase();
if (headerName == SAML_HEADER) {
const action = header.value.toLowerCase();
if (action == 'start') {
this.pendingIsSamlPage_ = true;
} else if (action == 'end') {
this.pendingIsSamlPage_ = false;
}
}
// If true, IdP tries to perform a device attestation.
// 300 <= .. <= 399 means it is a redirect to a page that will verify
// device response. HTTP header with
// |SAML_VERIFIED_ACCESS_CHALLENGE_HEADER| name contains challenge from
// Verified Access Web API.
if ((details.statusCode >= 300) && (details.statusCode <= 399) &&
(headerName == SAML_VERIFIED_ACCESS_CHALLENGE_HEADER)) {
this.deviceAttestationStage_ =
SamlHandler.DeviceAttestationStage.CHALLENGE_RECEIVED;
this.verifiedAccessChallenge_ = header.value;
}
}
return {};
}
/**
* Invoked when the injected JS makes a connection.
*/
onConnected_(port) {
if (port.targetWindow != this.webview_.contentWindow) {
return;
}
const channel = Channel.create();
channel.init(port);
channel.registerMessage('apiCall', this.onAPICall_.bind(this, channel));
channel.registerMessage(
'updatePassword', this.onUpdatePassword_.bind(this, channel));
channel.registerMessage(
'pageLoaded', this.onPageLoaded_.bind(this, channel));
channel.registerMessage(
'getSAMLFlag', this.onGetSAMLFlag_.bind(this, channel));
channel.registerMessage(
'scrollInfo', this.onScrollInfo_.bind(this, channel));
}
sendInitializationSuccess_(channel) {
channel.send({
name: 'apiResponse',
response: {
result: 'initialized',
version: this.apiVersion_,
keyTypes: API_KEY_TYPES
}
});
}
sendInitializationFailure_(channel) {
channel.send(
{name: 'apiResponse', response: {result: 'initialization_failed'}});
}
/**
* Handlers for channel messages.
* @param {Channel} channel A channel to send back response.
* @param {ApiCallMessage} msg Received message.
* @private
*/
onAPICall_(channel, msg) {
const call = msg.call;
if (call.method == 'initialize') {
if (!Number.isInteger(call.requestedVersion) ||
call.requestedVersion < MIN_API_VERSION_VERSION) {
this.sendInitializationFailure_(channel);
return;
}
this.apiVersion_ =
Math.min(call.requestedVersion, MAX_API_VERSION_VERSION);
this.apiInitialized_ = true;
this.sendInitializationSuccess_(channel);
return;
}
if (call.method == 'add') {
if (API_KEY_TYPES.indexOf(call.keyType) == -1) {
console.error('SamlHandler.onAPICall_: unsupported key type');
return;
}
// Not setting |email_| and |gaiaId_| because this API call will
// eventually be followed by onCompleteLogin_() which does set it.
this.apiTokenStore_[call.token] = call;
this.lastApiPasswordBytes_ = call.passwordBytes;
this.dispatchEvent(new CustomEvent('apiPasswordAdded'));
} else if (call.method == 'confirm') {
if (!(call.token in this.apiTokenStore_)) {
console.error('SamlHandler.onAPICall_: token mismatch');
} else {
this.confirmToken_ = call.token;
}
} else {
console.error('SamlHandler.onAPICall_: unknown message');
}
}
onUpdatePassword_(channel, msg) {
if (this.isSamlPage_) {
this.passwordStore_[msg.id] = msg.password;
}
}
onPageLoaded_(channel, msg) {
this.dispatchEvent(new CustomEvent(
'authPageLoaded', {detail: {isSAMLPage: this.isSamlPage_}}));
}
onScrollInfo_(channel, msg) {
const scrollTop = msg.scrollTop;
const scrollHeight = msg.scrollHeight;
const clientHeight = this.webview_.clientHeight;
if (scrollTop === undefined || scrollHeight === undefined) {
return;
}
this.webview_.classList.toggle('can-scroll', clientHeight < scrollHeight);
this.webview_.classList.toggle('is-scrolled', scrollTop > 0);
const scrolledToBottom = (scrollTop > 0) /*is-scrolled*/ &&
(Math.ceil(scrollTop + clientHeight) >= scrollHeight);
this.webview_.classList.toggle('scrolled-to-bottom', scrolledToBottom);
}
onPermissionRequest_(permissionEvent) {
if (permissionEvent.permission === 'media') {
// The actual permission check happens in
// WebUILoginView::RequestMediaAccessPermission().
this.dispatchEvent(new CustomEvent('videoEnabled'));
permissionEvent.request.allow();
}
}
onGetSAMLFlag_(channel, msg) {
return this.isSamlPage_;
}
}
// #cr_define_end
return {SamlHandler: SamlHandler};
});