diff --git a/res/css/_components.scss b/res/css/_components.scss
index 4fb0eed4af..f29e30dcb4 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -70,6 +70,7 @@
@import "./views/dialogs/_ShareDialog.scss";
@import "./views/dialogs/_UnknownDeviceDialog.scss";
@import "./views/dialogs/_UserSettingsDialog.scss";
+@import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss";
@import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss";
@import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss";
@import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss";
diff --git a/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss b/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss
new file mode 100644
index 0000000000..a419c105a9
--- /dev/null
+++ b/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss
@@ -0,0 +1,28 @@
+/*
+Copyright 2019 Travis Ralston
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_WidgetOpenIDPermissionsDialog .mx_SettingsFlag {
+ .mx_ToggleSwitch {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: 8px;
+ }
+
+ .mx_SettingsFlag_label {
+ display: inline-block;
+ vertical-align: middle;
+ }
+}
diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js
index ea7eeba756..4dd3ea6e6d 100644
--- a/src/FromWidgetPostMessageApi.js
+++ b/src/FromWidgetPostMessageApi.js
@@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
+Copyright 2019 Travis Ralston
Licensed under the Apache License, Version 2.0 (the 'License');
you may not use this file except in compliance with the License.
@@ -20,17 +21,19 @@ import IntegrationManager from './IntegrationManager';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
-const WIDGET_API_VERSION = '0.0.1'; // Current API version
+const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
'0.0.1',
+ '0.0.2',
];
const INBOUND_API_NAME = 'fromWidget';
-// Listen for and handle incomming requests using the 'fromWidget' postMessage
+// Listen for and handle incoming requests using the 'fromWidget' postMessage
// API and initiate responses
export default class FromWidgetPostMessageApi {
constructor() {
this.widgetMessagingEndpoints = [];
+ this.widgetListeners = {}; // {action: func[]}
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
@@ -45,6 +48,32 @@ export default class FromWidgetPostMessageApi {
window.removeEventListener('message', this.onPostMessage);
}
+ /**
+ * Adds a listener for a given action
+ * @param {string} action The action to listen for.
+ * @param {Function} callbackFn A callback function to be called when the action is
+ * encountered. Called with two parameters: the interesting request information and
+ * the raw event received from the postMessage API. The raw event is meant to be used
+ * for sendResponse and similar functions.
+ */
+ addListener(action, callbackFn) {
+ if (!this.widgetListeners[action]) this.widgetListeners[action] = [];
+ this.widgetListeners[action].push(callbackFn);
+ }
+
+ /**
+ * Removes a listener for a given action.
+ * @param {string} action The action that was subscribed to.
+ * @param {Function} callbackFn The original callback function that was used to subscribe
+ * to updates.
+ */
+ removeListener(action, callbackFn) {
+ if (!this.widgetListeners[action]) return;
+
+ const idx = this.widgetListeners[action].indexOf(callbackFn);
+ if (idx !== -1) this.widgetListeners[action].splice(idx, 1);
+ }
+
/**
* Register a widget endpoint for trusted postMessage communication
* @param {string} widgetId Unique widget identifier
@@ -117,6 +146,13 @@ export default class FromWidgetPostMessageApi {
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
}
+ // Call any listeners we have registered
+ if (this.widgetListeners[event.data.action]) {
+ for (const fn of this.widgetListeners[event.data.action]) {
+ fn(event.data, event);
+ }
+ }
+
// Although the requestId is required, we don't use it. We'll be nice and process the message
// if the property is missing, but with a warning for widget developers.
if (!event.data.requestId) {
@@ -164,6 +200,8 @@ export default class FromWidgetPostMessageApi {
if (ActiveWidgetStore.widgetHasCapability(widgetId, 'm.always_on_screen')) {
ActiveWidgetStore.setWidgetPersistence(widgetId, val);
}
+ } else if (action === 'get_openid') {
+ // Handled by caller
} else {
console.warn('Widget postMessage event unhandled');
this.sendError(event, {message: 'The postMessage was unhandled'});
diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js
index 5b722df65f..1d8e1b9cd3 100644
--- a/src/WidgetMessaging.js
+++ b/src/WidgetMessaging.js
@@ -1,5 +1,6 @@
/*
Copyright 2017 New Vector Ltd
+Copyright 2019 Travis Ralston
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -21,6 +22,11 @@ limitations under the License.
import FromWidgetPostMessageApi from './FromWidgetPostMessageApi';
import ToWidgetPostMessageApi from './ToWidgetPostMessageApi';
+import Modal from "./Modal";
+import MatrixClientPeg from "./MatrixClientPeg";
+import SettingsStore from "./settings/SettingsStore";
+import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
+import WidgetUtils from "./utils/WidgetUtils";
if (!global.mxFromWidgetMessaging) {
global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
@@ -34,12 +40,14 @@ if (!global.mxToWidgetMessaging) {
const OUTBOUND_API_NAME = 'toWidget';
export default class WidgetMessaging {
- constructor(widgetId, widgetUrl, target) {
+ constructor(widgetId, widgetUrl, isUserWidget, target) {
this.widgetId = widgetId;
this.widgetUrl = widgetUrl;
+ this.isUserWidget = isUserWidget;
this.target = target;
this.fromWidget = global.mxFromWidgetMessaging;
this.toWidget = global.mxToWidgetMessaging;
+ this._onOpenIdRequest = this._onOpenIdRequest.bind(this);
this.start();
}
@@ -109,9 +117,57 @@ export default class WidgetMessaging {
start() {
this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl);
+ this.fromWidget.addListener("get_openid", this._onOpenIdRequest);
}
stop() {
this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl);
+ this.fromWidget.removeListener("get_openid", this._onOpenIdRequest);
+ }
+
+ async _onOpenIdRequest(ev, rawEv) {
+ if (ev.widgetId !== this.widgetId) return; // not interesting
+
+ const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.widgetUrl, this.isUserWidget);
+
+ const settings = SettingsStore.getValue("widgetOpenIDPermissions");
+ if (settings.deny && settings.deny.includes(widgetSecurityKey)) {
+ this.fromWidget.sendResponse(rawEv, {state: "blocked"});
+ return;
+ }
+ if (settings.allow && settings.allow.includes(widgetSecurityKey)) {
+ const responseBody = {state: "allowed"};
+ const credentials = await MatrixClientPeg.get().getOpenIdToken();
+ Object.assign(responseBody, credentials);
+ this.fromWidget.sendResponse(rawEv, responseBody);
+ return;
+ }
+
+ // Confirm that we received the request
+ this.fromWidget.sendResponse(rawEv, {state: "request"});
+
+ // Actually ask for permission to send the user's data
+ Modal.createTrackedDialog("OpenID widget permissions", '',
+ WidgetOpenIDPermissionsDialog, {
+ widgetUrl: this.widgetUrl,
+ widgetId: this.widgetId,
+ isUserWidget: this.isUserWidget,
+
+ onFinished: async (confirm) => {
+ const responseBody = {success: confirm};
+ if (confirm) {
+ const credentials = await MatrixClientPeg.get().getOpenIdToken();
+ Object.assign(responseBody, credentials);
+ }
+ this.messageToWidget({
+ api: OUTBOUND_API_NAME,
+ action: "openid_credentials",
+ data: responseBody,
+ }).catch((error) => {
+ console.error("Failed to send OpenID credentials: ", error);
+ });
+ },
+ },
+ );
}
}
diff --git a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js
new file mode 100644
index 0000000000..62bd1d2521
--- /dev/null
+++ b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js
@@ -0,0 +1,103 @@
+/*
+Copyright 2019 Travis Ralston
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import {_t} from "../../../languageHandler";
+import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
+import sdk from "../../../index";
+import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
+import WidgetUtils from "../../../utils/WidgetUtils";
+
+export default class WidgetOpenIDPermissionsDialog extends React.Component {
+ static propTypes = {
+ onFinished: PropTypes.func.isRequired,
+ widgetUrl: PropTypes.string.isRequired,
+ widgetId: PropTypes.string.isRequired,
+ isUserWidget: PropTypes.bool.isRequired,
+ };
+
+ constructor() {
+ super();
+
+ this.state = {
+ rememberSelection: false,
+ };
+ }
+
+ _onAllow = () => {
+ this._onPermissionSelection(true);
+ };
+
+ _onDeny = () => {
+ this._onPermissionSelection(false);
+ };
+
+ _onPermissionSelection(allowed) {
+ if (this.state.rememberSelection) {
+ console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
+
+ const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
+ if (!currentValues.allow) currentValues.allow = [];
+ if (!currentValues.deny) currentValues.deny = [];
+
+ const securityKey = WidgetUtils.getWidgetSecurityKey(
+ this.props.widgetId,
+ this.props.widgetUrl,
+ this.props.isUserWidget);
+ (allowed ? currentValues.allow : currentValues.deny).push(securityKey);
+ SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
+ }
+
+ this.props.onFinished(allowed);
+ }
+
+ _onRememberSelectionChange = (newVal) => {
+ this.setState({rememberSelection: newVal});
+ };
+
+ render() {
+ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
+ const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
+
+ return (
+
+ {_t(
+ "A widget located at %(widgetUrl)s would like to verify your identity. " +
+ "By allowing this, the widget will be able to verify your user ID, but not " +
+ "perform actions as you.", {
+ widgetUrl: this.props.widgetUrl,
+ },
+ )}
+