From a2a7ec9087c5bd3e3920dcafec01aab589c312de Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Mon, 13 Feb 2017 21:12:12 -0800 Subject: [PATCH 01/22] Remove unused grainId parameter from ExternalWebSession. --- shell/server/drivers/external-ui-view.js | 8 +++----- shell/server/hack-session.js | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js index f3e400934d..c1b2bebb05 100644 --- a/shell/server/drivers/external-ui-view.js +++ b/shell/server/drivers/external-ui-view.js @@ -22,9 +22,8 @@ const Https = Npm.require("https"); const ApiSession = Capnp.importSystem("sandstorm/api-session.capnp").ApiSession; ExternalUiView = class ExternalUiView { - constructor(url, grainId, token) { + constructor(url, token) { this.url = url; - this.grainId = grainId; this.token = token; } @@ -41,7 +40,7 @@ ExternalUiView = class ExternalUiView { }; } - return { session: new Capnp.Capability(new ExternalWebSession(this.url, this.grainId, options), ApiSession) }; + return { session: new Capnp.Capability(new ExternalWebSession(this.url, options), ApiSession) }; } }; @@ -84,12 +83,11 @@ const responseCodes = { }; ExternalWebSession = class ExternalWebSession { - constructor(url, grainId, options) { + constructor(url, options) { const parsedUrl = Url.parse(url); this.host = parsedUrl.hostname; this.port = parsedUrl.port; this.protocol = parsedUrl.protocol; - this.grainId = grainId; this.options = options || {}; } diff --git a/shell/server/hack-session.js b/shell/server/hack-session.js index 8cad04d000..feda062278 100644 --- a/shell/server/hack-session.js +++ b/shell/server/hack-session.js @@ -511,9 +511,9 @@ HackSessionContextImpl = class HackSessionContextImpl extends SessionContextImpl const hostId = matchWildcardHost(parsedUrl.host); // Connecting to a remote server with a bearer token. // TODO(someday): Negotiate server-to-server Cap'n Proto connection. - return { view: new ExternalUiView(url, this.grainId, token) }; + return { view: new ExternalUiView(url, token) }; } else { - return { view: new ExternalUiView(url, this.grainId) }; + return { view: new ExternalUiView(url) }; } } }; From 23e7fb5fde3673ef5499b92f598755ce8c8c6b19 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 02:51:10 -0800 Subject: [PATCH 02/22] Catch exceptions properly in ExternalWebSession. --- shell/server/drivers/external-ui-view.js | 134 ++++++++++++----------- 1 file changed, 69 insertions(+), 65 deletions(-) diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js index c1b2bebb05..9259c3133c 100644 --- a/shell/server/drivers/external-ui-view.js +++ b/shell/server/drivers/external-ui-view.js @@ -164,75 +164,79 @@ ExternalWebSession = class ExternalWebSession { } req = requestMethod(options, (resp) => { - const buffers = []; - const statusInfo = responseCodes[resp.statusCode]; - - const rpcResponse = {}; - - switch (statusInfo.type) { - case "content": - resp.on("data", (buf) => { - buffers.push(buf); - }); - - resp.on("end", () => { - const content = {}; - rpcResponse.content = content; - - content.statusCode = statusInfo.code; - if ("content-encoding" in resp.headers) content.encoding = resp.headers["content-encoding"]; - if ("content-language" in resp.headers) content.language = resp.headers["content-language"]; - if ("content-type" in resp.headers) content.language = resp.headers["content-type"]; - if ("content-disposition" in resp.headers) { - const disposition = resp.headers["content-disposition"]; - const parts = disposition.split(";"); - if (parts[0].toLowerCase().trim() === "attachment") { - parts.forEach((part) => { - const splitPart = part.split("="); - if (splitPart[0].toLowerCase().trim() === "filename") { - content.disposition = { download: splitPart[1].trim() }; - } - }); + try { + const buffers = []; + const statusInfo = responseCodes[resp.statusCode]; + + const rpcResponse = {}; + + switch (statusInfo ? statusInfo.type : resp.statusCode) { + case "content": + resp.on("data", (buf) => { + buffers.push(buf); + }); + + resp.on("end", () => { + const content = {}; + rpcResponse.content = content; + + content.statusCode = statusInfo.code; + if ("content-encoding" in resp.headers) content.encoding = resp.headers["content-encoding"]; + if ("content-language" in resp.headers) content.language = resp.headers["content-language"]; + if ("content-type" in resp.headers) content.language = resp.headers["content-type"]; + if ("content-disposition" in resp.headers) { + const disposition = resp.headers["content-disposition"]; + const parts = disposition.split(";"); + if (parts[0].toLowerCase().trim() === "attachment") { + parts.forEach((part) => { + const splitPart = part.split("="); + if (splitPart[0].toLowerCase().trim() === "filename") { + content.disposition = { download: splitPart[1].trim() }; + } + }); + } } - } - content.body = {}; - content.body.bytes = Buffer.concat(buffers); + content.body = {}; + content.body.bytes = Buffer.concat(buffers); + resolve(rpcResponse); + }); + break; + case "noContent": + const noContent = {}; + rpcResponse.noContent = noContent; + noContent.setShouldResetForm = statusInfo.shouldResetForm; resolve(rpcResponse); - }); - break; - case "noContent": - const noContent = {}; - rpcResponse.noContent = noContent; - noContent.setShouldResetForm = statusInfo.shouldResetForm; - resolve(rpcResponse); - break; - case "redirect": - const redirect = {}; - rpcResponse.redirect = redirect; - redirect.isPermanent = statusInfo.isPermanent; - redirect.switchToGet = statusInfo.switchToGet; - if ("location" in resp.headers) redirect.location = resp.headers.location; - resolve(rpcResponse); - break; - case "clientError": - const clientError = {}; - rpcResponse.clientError = clientError; - clientError.statusCode = statusInfo.clientErrorCode; - clientError.descriptionHtml = statusInfo.descriptionHtml; - resolve(rpcResponse); - break; - case "serverError": - const serverError = {}; - rpcResponse.serverError = serverError; - clientError.descriptionHtml = statusInfo.descriptionHtml; - resolve(rpcResponse); - break; - default: // ??? - err = new Error("Invalid status code " + resp.statusCode + " received in response."); - reject(err); - break; + break; + case "redirect": + const redirect = {}; + rpcResponse.redirect = redirect; + redirect.isPermanent = statusInfo.isPermanent; + redirect.switchToGet = statusInfo.switchToGet; + if ("location" in resp.headers) redirect.location = resp.headers.location; + resolve(rpcResponse); + break; + case "clientError": + const clientError = {}; + rpcResponse.clientError = clientError; + clientError.statusCode = statusInfo.clientErrorCode; + clientError.descriptionHtml = statusInfo.descriptionHtml; + resolve(rpcResponse); + break; + case "serverError": + const serverError = {}; + rpcResponse.serverError = serverError; + clientError.descriptionHtml = statusInfo.descriptionHtml; + resolve(rpcResponse); + break; + default: // ??? + err = new Error("Invalid status code " + resp.statusCode + " received in response."); + reject(err); + break; + } + } catch (err) { + reject(err); } }); From b406da6c2be3d32f97b28e587b6b3c81595a856d Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 02:56:37 -0800 Subject: [PATCH 03/22] cleanup: Move builtin powerbox card templates to their own files. --- shell/client/powerbox-builtins.html | 69 ++++++ shell/client/powerbox-builtins.js | 215 ++++++++++++++++++ .../sandstorm-ui-powerbox/powerbox-client.js | 205 ----------------- .../sandstorm-ui-powerbox/powerbox.html | 70 ------ 4 files changed, 284 insertions(+), 275 deletions(-) create mode 100644 shell/client/powerbox-builtins.html create mode 100644 shell/client/powerbox-builtins.js diff --git a/shell/client/powerbox-builtins.html b/shell/client/powerbox-builtins.html new file mode 100644 index 0000000000..7b87b5757e --- /dev/null +++ b/shell/client/powerbox-builtins.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + diff --git a/shell/client/powerbox-builtins.js b/shell/client/powerbox-builtins.js new file mode 100644 index 0000000000..aaa520e8b4 --- /dev/null +++ b/shell/client/powerbox-builtins.js @@ -0,0 +1,215 @@ +// Sandstorm - Personal Cloud Sandbox +// Copyright (c) 2017 Sandstorm Development Group, Inc. and contributors +// All rights reserved. +// +// 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. + +const prepareViewInfoForDisplay = function (viewInfo) { + const result = _.clone(viewInfo || {}); + if (result.permissions) indexElements(result.permissions); + // It's essential that we index the roles *before* hiding obsolete roles, + // or else we'll produce the incorrect roleAssignment for roles that are + // described after obsolete roles in the pkgdef. + if (result.roles) { + indexElements(result.roles); + result.roles = removeObsolete(result.roles); + } + + return result; +}; + +const indexElements = function (arr) { + // Helper function to annotate an array of objects with their indices + for (let i = 0; i < arr.length; i++) { + arr[i].index = i; + } +}; + +const removeObsolete = function (arr) { + // remove entries from the list that are flagged as obsolete + return _.filter(arr, function (el) { + return !el.obsolete; + }); +}; + +Template.ipNetworkPowerboxCard.helpers({ + encryption: function () { + const encryption = this.option.frontendRef.ipNetwork.encryption || {}; + if ("tls" in encryption) { + return "TLS"; + } else { + return null; + } + }, +}); + +Template.grainPowerboxCard.powerboxIconSrc = card => { + return card.grainInfo.iconSrc; +}; + +Template.uiViewPowerboxConfiguration.onCreated(function () { + // this.data is a card; see filteredCardData() + + this._choseHostedObject = new ReactiveVar(false); + + this._viewInfo = new ReactiveVar({}); + + // Fetch the view info for the grain. + if (this.data.grainInfo.cachedViewInfo) { + this._viewInfo.set(prepareViewInfoForDisplay(this.data.grainInfo.cachedViewInfo)); + } else if (this.data.grainInfo.apiTokenId) { + Meteor.call("getViewInfoForApiToken", this.data.grainInfo.apiTokenId, (err, result) => { + if (err) { + console.log(err); + this.data.powerboxRequest.failRequest(err); + } else { + this._viewInfo.set(prepareViewInfoForDisplay(result)); + } + }); + } +}); + +Template.uiViewPowerboxConfiguration.helpers({ + choseHostedObject: function () { + return !this.option.uiView || Template.instance()._choseHostedObject.get(); + }, + + viewInfo: function () { + return Template.instance()._viewInfo.get(); + }, + + setupIframe: function () { + // HACK: A GrainView iframe has to be managed outside of the usual Blaze template flow and + // reactive contexts. We manually attach the iframe as a child of the "powerbox-iframe-mount" + // div and hope that that div doesn't get re-rendered unexpectedly. + // TODO(cleanup): This is terrible but what else can we do? + const tmpl = Template.instance(); + Meteor.defer(() => { + if (!tmpl._grainView) { + const mount = tmpl.find(".powerbox-iframe-mount"); + const powerboxRequest = { + descriptors: this.powerboxRequest.getQuery(), + requestingSession: this.powerboxRequest.getSessionId(), + }; + tmpl._grainView = new this.powerboxRequest.GrainView( + null, this.db, this.option.grainId, "", null, mount, { powerboxRequest }); + tmpl._grainView.setActive(true); + tmpl._grainView.openSession(); + + this.powerboxRequest.onFinalize(() => { + tmpl._grainView.destroy(); + }); + + tmpl.autorun(() => { + const fulfilledInfo = tmpl._grainView.fulfilledInfo(); + if (fulfilledInfo) { + this.powerboxRequest.completeRequest(fulfilledInfo.token, fulfilledInfo.descriptor); + } + }); + } + }); + }, +}); + +Template.uiViewPowerboxConfiguration.events({ + "click .connect-button": function (event) { + event.preventDefault(); + const selectedInput = Template.instance().find('form input[name="role"]:checked'); + if (selectedInput) { + let roleAssignment; + if (selectedInput.value === "all") { + roleAssignment = { allAccess: null }; + } else { + const role = parseInt(selectedInput.value, 10); + roleAssignment = { roleId: role }; + } + + this.powerboxRequest.completeUiView(this.option.grainId, roleAssignment); + } + }, + + "click .choose-hosted-object": function (event, tmpl) { + event.preventDefault(); + tmpl._choseHostedObject.set(true); + }, +}); + +const isSubsetOf = function (p1, p2) { + for (let idx = 0; idx < p1.length; ++idx) { + if (p1[idx] && !p2[idx]) { + return false; + } + } + + return true; +}; + +Template.identityPowerboxConfiguration.helpers({ + sufficientRoles: function () { + const requestedPermissions = this.option.requestedPermissions; + + const session = this.db.collections.sessions.findOne( + { _id: this.powerboxRequest._requestInfo.sessionId, }); + const roles = prepareViewInfoForDisplay(session.viewInfo).roles; + + return roles && roles.filter(r => isSubsetOf(requestedPermissions, r.permissions)); + }, +}); + +Template.identityPowerboxConfiguration.events({ + "click .connect-button": function (event, instance) { + event.preventDefault(); + const selectedInput = instance.find('form input[name="role"]:checked'); + if (selectedInput) { + let roleAssignment; + if (selectedInput.value === "all") { + roleAssignment = { allAccess: null }; + } else { + const role = parseInt(selectedInput.value, 10); + roleAssignment = { roleId: role }; + } + + this.powerboxRequest.completeNewFrontendRef({ + identity: { + id: instance.data.option.frontendRef.identity, + roleAssignment, + }, + }); + } + }, +}); + +Template.identityPowerboxCard.powerboxIconSrc = card => { + return card.option.profile.pictureUrl; +}; + +Template.emailVerifierPowerboxCard.helpers({ + serviceTitle: function () { + const services = this.option.frontendRef.emailVerifier.services; + const name = services[0]; + const service = Accounts.identityServices[name]; + if (service.loginTemplate.name === "oauthLoginButton") { + return service.loginTemplate.data.displayName; + } else if (name === "email") { + return "passwordless e-mail login"; + } else if (name === "ldap") { + return "LDAP"; + } else { + return name; + } + }, +}); + +Template.emailVerifierPowerboxCard.powerboxIconSrc = () => "/email-m.svg"; +Template.verifiedEmailPowerboxCard.powerboxIconSrc = () => "/email-m.svg"; +Template.addNewVerifiedEmailPowerboxCard.powerboxIconSrc = () => "/add-email-m.svg"; diff --git a/shell/packages/sandstorm-ui-powerbox/powerbox-client.js b/shell/packages/sandstorm-ui-powerbox/powerbox-client.js index f995f19877..557f69d7e1 100644 --- a/shell/packages/sandstorm-ui-powerbox/powerbox-client.js +++ b/shell/packages/sandstorm-ui-powerbox/powerbox-client.js @@ -267,34 +267,6 @@ const compileMatchFilter = function (searchString) { }; }; -const prepareViewInfoForDisplay = function (viewInfo) { - const result = _.clone(viewInfo || {}); - if (result.permissions) indexElements(result.permissions); - // It's essential that we index the roles *before* hiding obsolete roles, - // or else we'll produce the incorrect roleAssignment for roles that are - // described after obsolete roles in the pkgdef. - if (result.roles) { - indexElements(result.roles); - result.roles = removeObsolete(result.roles); - } - - return result; -}; - -const indexElements = function (arr) { - // Helper function to annotate an array of objects with their indices - for (let i = 0; i < arr.length; i++) { - arr[i].index = i; - } -}; - -const removeObsolete = function (arr) { - // remove entries from the list that are flagged as obsolete - return _.filter(arr, function (el) { - return !el.obsolete; - }); -}; - Template.powerboxRequest.onCreated(function () { this.autorun(() => { const request = this.data.get(); @@ -388,180 +360,3 @@ Template.powerboxRequest.events({ this.powerboxRequest.selectCard(this); }, }); - -// ======================================================================================= -// Templates for specific request types. -// -// TODO(cleanup): Find a better home for these. - -Template.ipNetworkPowerboxCard.helpers({ - encryption: function () { - const encryption = this.option.frontendRef.ipNetwork.encryption || {}; - if ("tls" in encryption) { - return "TLS"; - } else { - return null; - } - }, -}); - -Template.grainPowerboxCard.powerboxIconSrc = card => { - return card.grainInfo.iconSrc; -}; - -Template.uiViewPowerboxConfiguration.onCreated(function () { - // this.data is a card; see filteredCardData() - - this._choseHostedObject = new ReactiveVar(false); - - this._viewInfo = new ReactiveVar({}); - - // Fetch the view info for the grain. - if (this.data.grainInfo.cachedViewInfo) { - this._viewInfo.set(prepareViewInfoForDisplay(this.data.grainInfo.cachedViewInfo)); - } else if (this.data.grainInfo.apiTokenId) { - Meteor.call("getViewInfoForApiToken", this.data.grainInfo.apiTokenId, (err, result) => { - if (err) { - console.log(err); - this.data.powerboxRequest.failRequest(err); - } else { - this._viewInfo.set(prepareViewInfoForDisplay(result)); - } - }); - } -}); - -Template.uiViewPowerboxConfiguration.helpers({ - choseHostedObject: function () { - return !this.option.uiView || Template.instance()._choseHostedObject.get(); - }, - - viewInfo: function () { - return Template.instance()._viewInfo.get(); - }, - - setupIframe: function () { - // HACK: A GrainView iframe has to be managed outside of the usual Blaze template flow and - // reactive contexts. We manually attach the iframe as a child of the "powerbox-iframe-mount" - // div and hope that that div doesn't get re-rendered unexpectedly. - // TODO(cleanup): This is terrible but what else can we do? - const tmpl = Template.instance(); - Meteor.defer(() => { - if (!tmpl._grainView) { - const mount = tmpl.find(".powerbox-iframe-mount"); - const powerboxRequest = { - descriptors: this.powerboxRequest.getQuery(), - requestingSession: this.powerboxRequest.getSessionId(), - }; - tmpl._grainView = new this.powerboxRequest.GrainView( - null, this.db, this.option.grainId, "", null, mount, { powerboxRequest }); - tmpl._grainView.setActive(true); - tmpl._grainView.openSession(); - - this.powerboxRequest.onFinalize(() => { - tmpl._grainView.destroy(); - }); - - tmpl.autorun(() => { - const fulfilledInfo = tmpl._grainView.fulfilledInfo(); - if (fulfilledInfo) { - this.powerboxRequest.completeRequest(fulfilledInfo.token, fulfilledInfo.descriptor); - } - }); - } - }); - }, -}); - -Template.uiViewPowerboxConfiguration.events({ - "click .connect-button": function (event) { - event.preventDefault(); - const selectedInput = Template.instance().find('form input[name="role"]:checked'); - if (selectedInput) { - let roleAssignment; - if (selectedInput.value === "all") { - roleAssignment = { allAccess: null }; - } else { - const role = parseInt(selectedInput.value, 10); - roleAssignment = { roleId: role }; - } - - this.powerboxRequest.completeUiView(this.option.grainId, roleAssignment); - } - }, - - "click .choose-hosted-object": function (event, tmpl) { - event.preventDefault(); - tmpl._choseHostedObject.set(true); - }, -}); - -const isSubsetOf = function (p1, p2) { - for (let idx = 0; idx < p1.length; ++idx) { - if (p1[idx] && !p2[idx]) { - return false; - } - } - - return true; -}; - -Template.identityPowerboxConfiguration.helpers({ - sufficientRoles: function () { - const requestedPermissions = this.option.requestedPermissions; - - const session = this.db.collections.sessions.findOne( - { _id: this.powerboxRequest._requestInfo.sessionId, }); - const roles = prepareViewInfoForDisplay(session.viewInfo).roles; - - return roles && roles.filter(r => isSubsetOf(requestedPermissions, r.permissions)); - }, -}); - -Template.identityPowerboxConfiguration.events({ - "click .connect-button": function (event, instance) { - event.preventDefault(); - const selectedInput = instance.find('form input[name="role"]:checked'); - if (selectedInput) { - let roleAssignment; - if (selectedInput.value === "all") { - roleAssignment = { allAccess: null }; - } else { - const role = parseInt(selectedInput.value, 10); - roleAssignment = { roleId: role }; - } - - this.powerboxRequest.completeNewFrontendRef({ - identity: { - id: instance.data.option.frontendRef.identity, - roleAssignment, - }, - }); - } - }, -}); - -Template.identityPowerboxCard.powerboxIconSrc = card => { - return card.option.profile.pictureUrl; -}; - -Template.emailVerifierPowerboxCard.helpers({ - serviceTitle: function () { - const services = this.option.frontendRef.emailVerifier.services; - const name = services[0]; - const service = Accounts.identityServices[name]; - if (service.loginTemplate.name === "oauthLoginButton") { - return service.loginTemplate.data.displayName; - } else if (name === "email") { - return "passwordless e-mail login"; - } else if (name === "ldap") { - return "LDAP"; - } else { - return name; - } - }, -}); - -Template.emailVerifierPowerboxCard.powerboxIconSrc = () => "/email-m.svg"; -Template.verifiedEmailPowerboxCard.powerboxIconSrc = () => "/email-m.svg"; -Template.addNewVerifiedEmailPowerboxCard.powerboxIconSrc = () => "/add-email-m.svg"; diff --git a/shell/packages/sandstorm-ui-powerbox/powerbox.html b/shell/packages/sandstorm-ui-powerbox/powerbox.html index 1301934a12..1af6967cff 100644 --- a/shell/packages/sandstorm-ui-powerbox/powerbox.html +++ b/shell/packages/sandstorm-ui-powerbox/powerbox.html @@ -51,73 +51,3 @@

Select one:

{{/with}} {{/if}} - - - - - - - - - - - - - - - - - - - - From 3a6617f7301ffe91cd8cd26bcc96bfd3355abb7e Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 03:00:33 -0800 Subject: [PATCH 04/22] Add some TODOs to ApiSession.PowerboxTag. --- src/sandstorm/api-session.capnp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/sandstorm/api-session.capnp b/src/sandstorm/api-session.capnp index e9dbec86fa..1db8491cae 100644 --- a/src/sandstorm/api-session.capnp +++ b/src/sandstorm/api-session.capnp @@ -83,6 +83,11 @@ interface ApiSession @0xc879e379c625cdc7 extends(WebSession.WebSession) { # rules. That is, a grain may advertise that it can handle queries for HTTP APIs with a # particular `canonicalUrl`, indicating that the grain offers ApiSession capabilities # implementing a compatible protocol. + # + # TODO(soon): How do we request a standard protocol that doesn't have a canonical URL, like + # WebDAV? Does any of ApiSession.PowerboxTag even make sense in this case? + # TODO(soon): How do we request a single resource with a particular MIME type? Probably should + # be a separate interface, which http-bridge can implement... struct OAuthScope { name @0 :Text; From ad7c1e724eb5455e94aff12fa610b1e05f9f0342 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 03:01:46 -0800 Subject: [PATCH 05/22] Add api-session-impl.capnp to define PersistentApiSession. --- src/sandstorm/api-session-impl.capnp | 27 +++++++++++++++++++++++++++ src/sandstorm/sandstorm.ekam-manifest | 1 + 2 files changed, 28 insertions(+) create mode 100644 src/sandstorm/api-session-impl.capnp diff --git a/src/sandstorm/api-session-impl.capnp b/src/sandstorm/api-session-impl.capnp new file mode 100644 index 0000000000..bab235b292 --- /dev/null +++ b/src/sandstorm/api-session-impl.capnp @@ -0,0 +1,27 @@ +# Sandstorm - Personal Cloud Sandbox +# Copyright (c) 2017 Sandstorm Development Group, Inc. and contributors +# All rights reserved. +# +# 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. + +# This is used specifically with hack-session.capnp. +# It is subject to change after the Powerbox functionality is implemented. + +@0xa949cfa7be07085b; + +$import "/capnp/c++.capnp".namespace("sandstorm"); + +using ApiSession = import "api-session.capnp".ApiSession; +using SystemPersistent = import "supervisor.capnp".SystemPersistent; + +interface PersistentApiSession extends (ApiSession, SystemPersistent) {} diff --git a/src/sandstorm/sandstorm.ekam-manifest b/src/sandstorm/sandstorm.ekam-manifest index 0615f27173..0a1aad2cfe 100644 --- a/src/sandstorm/sandstorm.ekam-manifest +++ b/src/sandstorm/sandstorm.ekam-manifest @@ -5,6 +5,7 @@ email.capnp node_modules/sandstorm/email.capnp email-impl.capnp node_modules/sandstorm/email-impl.capnp grain.capnp node_modules/sandstorm/grain.capnp api-session.capnp node_modules/sandstorm/api-session.capnp +api-session-impl.capnp node_modules/sandstorm/api-session-impl.capnp hack-session.capnp node_modules/sandstorm/hack-session.capnp ip.capnp node_modules/sandstorm/ip.capnp package.capnp node_modules/sandstorm/package.capnp From 7c1993d72491b804ec6ea8aa7c609050077859a0 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 03:03:53 -0800 Subject: [PATCH 06/22] Define external HTTP frontendRef schema. --- shell/packages/sandstorm-db/db.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/shell/packages/sandstorm-db/db.js b/shell/packages/sandstorm-db/db.js index d029dedb1b..20dbc6db7d 100644 --- a/shell/packages/sandstorm-db/db.js +++ b/shell/packages/sandstorm-db/db.js @@ -426,6 +426,14 @@ ApiTokens = new Mongo.Collection("apiTokens", collectionOptions); // verifiedEmail: An VerifiedEmail capability that is implemented by the frontend. // An object containing `verifierId`, `tabId`, and `address`. // identity: An Identity capability. The field is the identity ID. +// http: An ApiSession capability pointing to an external HTTP service. Object containing: +// url: Base URL of the external service. +// auth: Authentitation mechanism. Object containing one of: +// none: Value "null". Indicates no authorization. +// bearer: A bearer token to pass in the `Authorization: Bearer` header on all +// requests. +// basic: A `{username, password}` object. +// refresh: An OAuth refresh token, which can be exchanged for an access token. // parentToken: If present, then this token represents exactly the capability represented by // the ApiToken with _id = parentToken, except possibly (if it is a UiView) attenuated // by `roleAssignment` (if present). To facilitate permissions computations, if the @@ -492,6 +500,15 @@ ApiTokens = new Mongo.Collection("apiTokens", collectionOptions); // address :Text; // } // identity :Text; +// http :group { +// url :Text; +// auth :union { +// none :Void; +// bearer :Text; +// basic :group { username :Text; password :Text; } +// refresh :Text; +// } +// } // } // child :group { // parentToken :Text; From 23603efbab9fe7a4e68408a094df272e57113a68 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 03:26:35 -0800 Subject: [PATCH 07/22] Support simple outgoing HTTP powerbox options. So far, this includes: * Offering the canonicalUrl as a one-click option, but with no authentication. * Offering an option to enter a URL manually. * If the URL contains username/password or is a webkey, the session will be authenticated as specified. --- shell/client/powerbox-builtins.html | 18 ++++ shell/client/powerbox-builtins.js | 17 ++++ shell/client/styles/_powerbox.scss | 6 ++ shell/server/drivers/external-ui-view.js | 116 ++++++++++++++++++++++- 4 files changed, 155 insertions(+), 2 deletions(-) diff --git a/shell/client/powerbox-builtins.html b/shell/client/powerbox-builtins.html index 7b87b5757e..aa4ff35640 100644 --- a/shell/client/powerbox-builtins.html +++ b/shell/client/powerbox-builtins.html @@ -67,3 +67,21 @@ + + + + + + diff --git a/shell/client/powerbox-builtins.js b/shell/client/powerbox-builtins.js index aaa520e8b4..43cad41e42 100644 --- a/shell/client/powerbox-builtins.js +++ b/shell/client/powerbox-builtins.js @@ -213,3 +213,20 @@ Template.emailVerifierPowerboxCard.helpers({ Template.emailVerifierPowerboxCard.powerboxIconSrc = () => "/email-m.svg"; Template.verifiedEmailPowerboxCard.powerboxIconSrc = () => "/email-m.svg"; Template.addNewVerifiedEmailPowerboxCard.powerboxIconSrc = () => "/add-email-m.svg"; + +Template.httpUrlPowerboxCard.powerboxIconSrc = () => "/web-m.svg"; + +Template.httpArbitraryPowerboxCard.powerboxIconSrc = () => "/web-m.svg"; +Template.httpArbitraryPowerboxConfiguration.events({ + "click .connect-button": function (event, instance) { + event.preventDefault(); + const input = instance.find("form>input.url"); + + this.powerboxRequest.completeNewFrontendRef({ + http: { + url: input.value, + auth: { none: null }, + }, + }); + }, +}); diff --git a/shell/client/styles/_powerbox.scss b/shell/client/styles/_powerbox.scss index 9457fba2b5..c71d31a95d 100644 --- a/shell/client/styles/_powerbox.scss +++ b/shell/client/styles/_powerbox.scss @@ -83,8 +83,14 @@ body>.popup.request>.frame-container>.frame { >button { margin-top: 10px; } + input[type="text"] { + width: 100%; + font-size: inherit; + padding: 4px; + } } div.choose-buttons { + text-align: right; >button { @extend %button-base; &.connect-button { diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js index 9259c3133c..ecb2c3829f 100644 --- a/shell/server/drivers/external-ui-view.js +++ b/shell/server/drivers/external-ui-view.js @@ -14,12 +14,16 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { PersistentImpl } from "/imports/server/persistent.js"; + const Future = Npm.require("fibers/future"); const Capnp = Npm.require("capnp"); const Url = Npm.require("url"); const Http = Npm.require("http"); const Https = Npm.require("https"); const ApiSession = Capnp.importSystem("sandstorm/api-session.capnp").ApiSession; +const PersistentApiSession = + Capnp.importSystem("sandstorm/api-session-impl.capnp").PersistentApiSession; ExternalUiView = class ExternalUiView { constructor(url, token) { @@ -44,6 +48,112 @@ ExternalUiView = class ExternalUiView { } }; +function newExternalHttpSession(url, auth, db, saveTemplate) { + // `url` and `auth` are the corresponding members of `ApiToken.frontendRef.http`. + + const createCap = authorization => { + return new Capnp.Capability(new ExternalWebSession(url, + authorization ? { headers: { authorization } } : {}, + db, saveTemplate), PersistentApiSession); + }; + + if (auth.refresh) { + throw new Error("refresh tokens unimplemented"); + } else if (auth.bearer) { + return createCap("Bearer " + auth.bearer); + } else if (auth.basic) { + const userpass = [auth.basic.username, auth.basic.password].join(":"); + return createCap("Basic " + new Buffer(userpass, "utf8").toString("base64")); + } else { + return createCap(null); + } +} + +function registerHttpApiFrontendRef(registry) { + registry.register({ + frontendRefField: "http", + typeId: "14445827391922490823", + + restore(db, saveTemplate, value) { + return newExternalHttpSession(value.url, value.auth, db, saveTemplate); + }, + + validate(db, session, request) { + check(request, { + url: String, + auth: Match.OneOf( + { none: null }, + { bearer: String }, + { basic: { username: String, password: String } }), + }); + + if (!request.url.startsWith("https://") && + !request.url.startsWith("http://")) { + throw new Meteor.Error(400, "URL must be HTTP or HTTPS."); + } + + // Check for URL patterns. + const parsedUrl = Url.parse(request.url); + + if (parsedUrl.auth) { + const parts = parsedUrl.auth.split(":"); + if (parts.length === 2) { + if ("none" in request.auth) { + request.auth = { basic: { username: parts[0], password: parts[1] } }; + } else { + throw new Meteor.Error(400, "Can't supprot multiple authentication mechanisms at once"); + } + } + + parsedUrl.auth = null; + request.url = Url.format(parsedUrl); + } + + if (parsedUrl.hash) { + if ("none" in request.auth) { + request.auth = { bearer: parsedUrl.hash.slice(1) }; + } else { + throw new Meteor.Error(400, "Can't supprot multiple authentication mechanisms at once"); + } + + parsedUrl.hash = null; + request.url = Url.format(parsedUrl); + } + + const descriptor = { tags: [ { id: ApiSession.typeId } ] }; + return { descriptor, requirements: [], frontendRef: request }; + }, + + query(db, userAccountId, tagValue) { + const tag = tagValue ? Capnp.parse(ApiSession.PowerboxTag, tagValue) : {}; + + const options = []; + + if (tag.canonicalUrl && + (tag.canonicalUrl.startsWith("https://") || + tag.canonicalUrl.startsWith("http://"))) { + options.push({ + _id: "http-url-" + tag.canonicalUrl, + frontendRef: { http: { url: tag.canonicalUrl, auth: { none: null } } }, + cardTemplate: "httpUrlPowerboxCard", + }); + } + + options.push({ + _id: "http-arbitrary", + cardTemplate: "httpArbitraryPowerboxCard", + configureTemplate: "httpArbitraryPowerboxConfiguration", + }); + + return options; + }, + }); +} + +Meteor.startup(() => { registerHttpApiFrontendRef(globalFrontendRefRegistry); }); + +// ======================================================================================= + const responseCodes = { 200: { type: "content", code: "ok" }, 201: { type: "content", code: "created" }, @@ -82,8 +192,10 @@ const responseCodes = { 505: { type: "serverError" }, }; -ExternalWebSession = class ExternalWebSession { - constructor(url, options) { +ExternalWebSession = class ExternalWebSession extends PersistentImpl { + constructor(url, options, db, saveTemplate) { + super(db, saveTemplate); + const parsedUrl = Url.parse(url); this.host = parsedUrl.hostname; this.port = parsedUrl.port; From 2c952c0ec8de9266fca72c849d82611595ca8c55 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 03:31:49 -0800 Subject: [PATCH 08/22] Implement OAuth-authenticated HTTP powerbox options. --- shell/.meteor/packages | 3 + shell/client/powerbox-builtins.html | 8 ++ shell/client/powerbox-builtins.js | 30 +++++++ shell/server/drivers/external-ui-view.js | 106 +++++++++++++++++++++-- 4 files changed, 140 insertions(+), 7 deletions(-) diff --git a/shell/.meteor/packages b/shell/.meteor/packages index 6d8b0f1d2c..3bcedb5433 100644 --- a/shell/.meteor/packages +++ b/shell/.meteor/packages @@ -41,3 +41,6 @@ standard-minifier-js@1.2.1 shell-server@0.2.1 oauth@1.1.12 +service-configuration +google +github diff --git a/shell/client/powerbox-builtins.html b/shell/client/powerbox-builtins.html index aa4ff35640..071c3e865d 100644 --- a/shell/client/powerbox-builtins.html +++ b/shell/client/powerbox-builtins.html @@ -85,3 +85,11 @@ + + + + diff --git a/shell/client/powerbox-builtins.js b/shell/client/powerbox-builtins.js index 43cad41e42..a978f90ad5 100644 --- a/shell/client/powerbox-builtins.js +++ b/shell/client/powerbox-builtins.js @@ -230,3 +230,33 @@ Template.httpArbitraryPowerboxConfiguration.events({ }); }, }); + +Template.httpOAuthPowerboxCard.powerboxIconSrc = () => "/web-m.svg"; +Template.httpOAuthPowerboxConfiguration.onCreated(function () { + const option = this.data.option; + + const serviceMap = { google: Google, github: Github }; + + const serviceHandler = serviceMap[option.oauthServiceInfo.service]; + + if (!serviceHandler) { + throw new Error("unknown service: " + option.oauthServiceInfo.service); + } + + serviceHandler.requestCredential({ + loginStyle: "popup", + requestPermissions: option.oauthScopes.map(scope => scope.name), + + // Google-specific options... others should ignore. + forceApprovalPrompt: true, + requestOfflineToken: true, + }, credentialToken => { + const credentialSecret = OAuth._retrieveCredentialSecret(credentialToken); + this.data.powerboxRequest.completeNewFrontendRef({ + http: { + url: option.httpUrl, + auth: { oauth: { credentialToken, credentialSecret } }, + }, + }); + }); +}); diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js index ecb2c3829f..28ed54539d 100644 --- a/shell/server/drivers/external-ui-view.js +++ b/shell/server/drivers/external-ui-view.js @@ -24,6 +24,7 @@ const Https = Npm.require("https"); const ApiSession = Capnp.importSystem("sandstorm/api-session.capnp").ApiSession; const PersistentApiSession = Capnp.importSystem("sandstorm/api-session-impl.capnp").PersistentApiSession; +const Request = HTTPInternals.NpmModules.request; ExternalUiView = class ExternalUiView { constructor(url, token) { @@ -48,6 +49,57 @@ ExternalUiView = class ExternalUiView { } }; +function getOAuthServiceInfo(url) { + // TODO(soon): Define a table somewhere (probably in a .capnp file) mapping API hosts to OAuth + // metadata. + if (url.startsWith("https://apidata.googleusercontent.com/") || + url.startsWith("https://www.googleapis.com/")) { + return { + service: "google", + endpoint: "https://www.googleapis.com/oauth2/v4/token", + }; + } else if (url.startsWith("https://api.github.com/users")) { + return { + service: "github", + endpoint: "https://github.com/login/oauth/access_token", + }; + } else { + return null; + } +} + +function refreshOAuth(url, refreshToken) { + // TODO(perf): Cache access tokens until they expire? Currently we re-do the refresh on every + // restore. In particular, this means we always drop the first access token returned (which + // is returned together with the refresh token) and then immediately request a new one. + + const serviceInfo = getOAuthServiceInfo(url); + if (!serviceInfo) { + throw new Error("Don't know how to OAuth for: " + url); + } + + const config = ServiceConfiguration.configurations.findOne({ service: serviceInfo.service }); + if (!config) { + throw new Error("can't refresh OAuth token for service that isn't configured: " + + serviceInfo.service); + } + + const response = HTTP.post(serviceInfo.endpoint, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + content: "client_id=" + encodeURIComponent(config.clientId) + + "&client_secret=" + encodeURIComponent(config.secret) + + "&refresh_token=" + encodeURIComponent(refreshToken) + + "&grant_type=refresh_token", + }); + + console.log(response.data); + + return response.data; +} + function newExternalHttpSession(url, auth, db, saveTemplate) { // `url` and `auth` are the corresponding members of `ApiToken.frontendRef.http`. @@ -58,7 +110,7 @@ function newExternalHttpSession(url, auth, db, saveTemplate) { }; if (auth.refresh) { - throw new Error("refresh tokens unimplemented"); + return createCap("Bearer " + refreshOAuth(url, auth.refresh).access_token); } else if (auth.bearer) { return createCap("Bearer " + auth.bearer); } else if (auth.basic) { @@ -84,7 +136,8 @@ function registerHttpApiFrontendRef(registry) { auth: Match.OneOf( { none: null }, { bearer: String }, - { basic: { username: String, password: String } }), + { basic: { username: String, password: String } }, + { oauth: { credentialToken: String, credentialSecret: String } }), }); if (!request.url.startsWith("https://") && @@ -120,6 +173,28 @@ function registerHttpApiFrontendRef(registry) { request.url = Url.format(parsedUrl); } + if (request.auth.oauth) { + // We did an OAuth handshake client-side. + const oauthResult = OAuth.retrieveCredential(request.auth.oauth.credentialToken, + request.auth.oauth.credentialSecret); + + if (oauthResult instanceof Error) { + throw oauthResult; + } + + const serviceData = oauthResult.serviceData; + if (serviceData.refreshToken) { + request.auth = { refresh: serviceData.refreshToken }; + } else { + request.auth = { bearer: serviceData.accessToken }; + } + + // TODO(security): We could maybe add a MembraneRequirement that this user account + // possesses credentials for this OAuth service. Conversely, perhaps if an authCode was + // specified, we should automatically add the associated credential to the user's + // account? (As a non-login credential.) + } + const descriptor = { tags: [ { id: ApiSession.typeId } ] }; return { descriptor, requirements: [], frontendRef: request }; }, @@ -132,11 +207,28 @@ function registerHttpApiFrontendRef(registry) { if (tag.canonicalUrl && (tag.canonicalUrl.startsWith("https://") || tag.canonicalUrl.startsWith("http://"))) { - options.push({ - _id: "http-url-" + tag.canonicalUrl, - frontendRef: { http: { url: tag.canonicalUrl, auth: { none: null } } }, - cardTemplate: "httpUrlPowerboxCard", - }); + const serviceInfo = getOAuthServiceInfo(tag.canonicalUrl); + if (serviceInfo && tag.oauthScopes) { + // Note: We don't check if the service is configured, because it's useful to show the + // user that the option exists but inform them that it will only work if the admin + // configures this service. + options.push({ + _id: "http-oauth", + cardTemplate: "httpOAuthPowerboxCard", + configureTemplate: "httpOAuthPowerboxConfiguration", + httpUrl: tag.canonicalUrl, + oauthServiceInfo: serviceInfo, + oauthScopes: tag.oauthScopes, + }); + } else { + // TODO(soon): Support tag.authentication. + + options.push({ + _id: "http-url-" + tag.canonicalUrl, + frontendRef: { http: { url: tag.canonicalUrl, auth: { none: null } } }, + cardTemplate: "httpUrlPowerboxCard", + }); + } } options.push({ From bc7525f1cc6332e8e8b5e893b83c013c8953ab77 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 03:32:16 -0800 Subject: [PATCH 09/22] Fix broken path handling in ExternalWebSession. --- shell/server/drivers/external-ui-view.js | 35 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js index 28ed54539d..8ddfb4914b 100644 --- a/shell/server/drivers/external-ui-view.js +++ b/shell/server/drivers/external-ui-view.js @@ -288,8 +288,25 @@ ExternalWebSession = class ExternalWebSession extends PersistentImpl { constructor(url, options, db, saveTemplate) { super(db, saveTemplate); + if (!saveTemplate) { + // enable backwards-compatibilty tweaks. + this.fromHackSession = true; + } + const parsedUrl = Url.parse(url); this.host = parsedUrl.hostname; + if (this.fromHackSession) { + // HackSessionContext.getExternalUiView() apparently ignored any path on the URL. Whoops. + } else { + if (parsedUrl.path === "/") { + // The URL parser says path = "/" for both "http://foo" and "http://foo/". We want to be + // strict, though. + this.path = url.endsWith("/") ? "/" : ""; + } else { + this.path = parsedUrl.path; + } + } + this.port = parsedUrl.port; this.protocol = parsedUrl.protocol; this.options = options || {}; @@ -328,13 +345,17 @@ ExternalWebSession = class ExternalWebSession extends PersistentImpl { const options = _.clone(session.options); options.headers = options.headers || {}; - // According to the specification of `WebSession`, `path` should not contain a - // leading slash, and therefore we need to prepend "/". However, for a long time - // this implementation did not in fact prepend a "/". Since some apps might rely on - // that behavior, we only prepend "/" if the path does not start with "/". - // - // TODO(soon): Once apps have updated, prepend "/" unconditionally. - options.path = path.startsWith("/") ? path : "/" + path; + if (this.fromHackSession) { + // According to the specification of `WebSession`, `path` should not contain a + // leading slash, and therefore we need to prepend "/". However, for a long time + // this implementation did not in fact prepend a "/". Since some apps might rely on + // that behavior, we only prepend "/" if the path does not start with "/". + // + // TODO(soon): Once apps have updated, prepend "/" unconditionally. + options.path = path.startsWith("/") ? path : "/" + path; + } else { + options.path = this.path + "/" + path; + } options.method = method; if (contentType) { From 6992b5b5da6b13a95979a03a151fb9818eada8e8 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 03:36:18 -0800 Subject: [PATCH 10/22] Add some TODOs to ExternalWebSession. --- shell/server/drivers/external-ui-view.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js index 8ddfb4914b..21c453c4a9 100644 --- a/shell/server/drivers/external-ui-view.js +++ b/shell/server/drivers/external-ui-view.js @@ -446,15 +446,21 @@ ExternalWebSession = class ExternalWebSession extends PersistentImpl { const clientError = {}; rpcResponse.clientError = clientError; clientError.statusCode = statusInfo.clientErrorCode; + // TODO(soon): Pass along the body from upstream. clientError.descriptionHtml = statusInfo.descriptionHtml; resolve(rpcResponse); break; case "serverError": const serverError = {}; rpcResponse.serverError = serverError; + // TODO(soon): Pass along the body from upstream. clientError.descriptionHtml = statusInfo.descriptionHtml; resolve(rpcResponse); break; + + // TODO(soon): Handle token-expired errors by throwing DISCONNECTED -- this will force + // the client to reload the capability which will refresh the token. + default: // ??? err = new Error("Invalid status code " + resp.statusCode + " received in response."); reject(err); From 9df7488e6e70eb2b1c9e2ffca331ecf142ee637c Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 03:42:08 -0800 Subject: [PATCH 11/22] cleanup: Fix weird use of undeclared dependency on ServiceConfiguration in admin-server.js. This dependency is now explicit, so this hack isn't needed anymore. (Why it was ever used, I'm not sure.) --- shell/client/accounts/login-buttons.js | 1 - shell/client/admin/identity-providers.js | 4 ++-- shell/imports/server/migrations.js | 2 +- shell/server/admin-server.js | 5 +---- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/shell/client/accounts/login-buttons.js b/shell/client/accounts/login-buttons.js index b3bc847149..570942129b 100644 --- a/shell/client/accounts/login-buttons.js +++ b/shell/client/accounts/login-buttons.js @@ -29,7 +29,6 @@ import AccountsUi from "/imports/client/accounts/accounts-ui.js"; // for convenience const loginButtonsSession = Accounts._loginButtonsSession; -const ServiceConfiguration = Package["service-configuration"].ServiceConfiguration; const isDemoUserHelper = function () { return this._db.isDemoUser(); diff --git a/shell/client/admin/identity-providers.js b/shell/client/admin/identity-providers.js index 6640201b39..a56c68885f 100644 --- a/shell/client/admin/identity-providers.js +++ b/shell/client/admin/identity-providers.js @@ -177,7 +177,7 @@ Template.googleLoginSetupInstructions.helpers({ // Google form. Template.adminIdentityProviderConfigureGoogle.onCreated(function () { - const configurations = Package["service-configuration"].ServiceConfiguration.configurations; + const configurations = ServiceConfiguration.configurations; const googleConfiguration = configurations.findOne({ service: "google" }); const clientId = (googleConfiguration && googleConfiguration.clientId) || ""; const clientSecret = (googleConfiguration && googleConfiguration.secret) || ""; @@ -283,7 +283,7 @@ Template.githubLoginSetupInstructions.helpers({ // GitHub form. Template.adminIdentityProviderConfigureGitHub.onCreated(function () { - const configurations = Package["service-configuration"].ServiceConfiguration.configurations; + const configurations = ServiceConfiguration.configurations; const githubConfiguration = configurations.findOne({ service: "github" }); const clientId = (githubConfiguration && githubConfiguration.clientId) || ""; const clientSecret = (githubConfiguration && githubConfiguration.secret) || ""; diff --git a/shell/imports/server/migrations.js b/shell/imports/server/migrations.js index 9888de8fbf..658018411c 100644 --- a/shell/imports/server/migrations.js +++ b/shell/imports/server/migrations.js @@ -41,7 +41,7 @@ const enableLegacyOAuthProvidersIfNotInSettings = function (db, backend) { // explicitly enable it in Settings, and then the rest of the logic can just // depend on what value is in Settings and default to false without breaking // user installations. - const configurations = Package["service-configuration"].ServiceConfiguration.configurations; + const configurations = ServiceConfiguration.configurations; ["google", "github"].forEach(function (serviceName) { const config = configurations.findOne({ service: serviceName }); const serviceConfig = db.collections.settings.findOne({ _id: serviceName }); diff --git a/shell/server/admin-server.js b/shell/server/admin-server.js index 9e70175356..c1f72f34c5 100644 --- a/shell/server/admin-server.js +++ b/shell/server/admin-server.js @@ -67,7 +67,6 @@ Meteor.methods({ // Only check configurations for OAuth services. const oauthServices = ["google", "github"]; if (value && (oauthServices.indexOf(serviceName) != -1)) { - const ServiceConfiguration = Package["service-configuration"].ServiceConfiguration; const config = ServiceConfiguration.configurations.findOne({ service: serviceName }); if (!config) { throw new Meteor.Error(403, "You must configure the " + serviceName + @@ -141,8 +140,6 @@ Meteor.methods({ checkAuth(token); check(options, Match.ObjectIncluding({ service: String })); - const ServiceConfiguration = Package["service-configuration"].ServiceConfiguration; - ServiceConfiguration.configurations.upsert({ service: options.service }, options); }, @@ -385,7 +382,7 @@ Meteor.publish("admin", function (token) { Meteor.publish("adminServiceConfiguration", function (token) { if (!authorizedAsAdmin(token, this.userId)) return []; - return Package["service-configuration"].ServiceConfiguration.configurations.find(); + return ServiceConfiguration.configurations.find(); }); Meteor.publish("publicAdminSettings", function () { From c80c7c3c0a41a662b023449b5ddf5a01d4fc5662 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 04:03:57 -0800 Subject: [PATCH 12/22] jscs --fix --- shell/server/drivers/external-ui-view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js index 21c453c4a9..dbb64c5538 100644 --- a/shell/server/drivers/external-ui-view.js +++ b/shell/server/drivers/external-ui-view.js @@ -195,7 +195,7 @@ function registerHttpApiFrontendRef(registry) { // account? (As a non-login credential.) } - const descriptor = { tags: [ { id: ApiSession.typeId } ] }; + const descriptor = { tags: [{ id: ApiSession.typeId }] }; return { descriptor, requirements: [], frontendRef: request }; }, From 2a7458da79c51221ec4db23cf07ba426771afa4e Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 14:15:09 -0800 Subject: [PATCH 13/22] Address @dwrensha's review comments. --- shell/server/drivers/external-ui-view.js | 10 +++++----- src/sandstorm/api-session-impl.capnp | 3 --- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js index dbb64c5538..ab0a3a15fb 100644 --- a/shell/server/drivers/external-ui-view.js +++ b/shell/server/drivers/external-ui-view.js @@ -95,8 +95,6 @@ function refreshOAuth(url, refreshToken) { + "&grant_type=refresh_token", }); - console.log(response.data); - return response.data; } @@ -124,7 +122,7 @@ function newExternalHttpSession(url, auth, db, saveTemplate) { function registerHttpApiFrontendRef(registry) { registry.register({ frontendRefField: "http", - typeId: "14445827391922490823", + typeId: ApiSession.typeId, restore(db, saveTemplate, value) { return newExternalHttpSession(value.url, value.auth, db, saveTemplate); @@ -154,7 +152,7 @@ function registerHttpApiFrontendRef(registry) { if ("none" in request.auth) { request.auth = { basic: { username: parts[0], password: parts[1] } }; } else { - throw new Meteor.Error(400, "Can't supprot multiple authentication mechanisms at once"); + throw new Meteor.Error(400, "Can't support multiple authentication mechanisms at once"); } } @@ -213,7 +211,7 @@ function registerHttpApiFrontendRef(registry) { // user that the option exists but inform them that it will only work if the admin // configures this service. options.push({ - _id: "http-oauth", + _id: "http-oauth-" + tag.canonicalUrl, cardTemplate: "httpOAuthPowerboxCard", configureTemplate: "httpOAuthPowerboxConfiguration", httpUrl: tag.canonicalUrl, @@ -231,6 +229,8 @@ function registerHttpApiFrontendRef(registry) { } } + // Always offer the user the option to connect to an arbitrary URL of their choosing, even + // if canonicalUrl exists. options.push({ _id: "http-arbitrary", cardTemplate: "httpArbitraryPowerboxCard", diff --git a/src/sandstorm/api-session-impl.capnp b/src/sandstorm/api-session-impl.capnp index bab235b292..81235d1ea1 100644 --- a/src/sandstorm/api-session-impl.capnp +++ b/src/sandstorm/api-session-impl.capnp @@ -14,9 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -# This is used specifically with hack-session.capnp. -# It is subject to change after the Powerbox functionality is implemented. - @0xa949cfa7be07085b; $import "/capnp/c++.capnp".namespace("sandstorm"); From aa2505184a1e1556a706f5294210e2ae303d3a8b Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 14:19:00 -0800 Subject: [PATCH 14/22] Add comment about revoking OAuth tokens when deleting ApiToken records. --- shell/packages/sandstorm-db/db.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shell/packages/sandstorm-db/db.js b/shell/packages/sandstorm-db/db.js index 20dbc6db7d..a082d13b78 100644 --- a/shell/packages/sandstorm-db/db.js +++ b/shell/packages/sandstorm-db/db.js @@ -1176,6 +1176,10 @@ if (Meteor.isServer) { const hash2 = Crypto.createHash("sha256").update(token._id).digest("base64"); this.collections.apiHosts.remove({ hash2: hash2 }); } + + // TODO(soon): Drop remote OAuth tokens for frontendRef.http. Unfortunately the way to do + // this is different for every service. :( Also we may need to clarify with the "bearer" + // type whether or not the token is "owned" by us... }); this.collections.apiTokens.remove(query); From 3b66bc8baa3a969306b732b0e67657b2a44c3a09 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 20:07:09 -0800 Subject: [PATCH 15/22] Document how we encrypt credentials in ApiTokens. --- shell/packages/sandstorm-db/db.js | 47 +++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/shell/packages/sandstorm-db/db.js b/shell/packages/sandstorm-db/db.js index a082d13b78..4418727935 100644 --- a/shell/packages/sandstorm-db/db.js +++ b/shell/packages/sandstorm-db/db.js @@ -431,9 +431,16 @@ ApiTokens = new Mongo.Collection("apiTokens", collectionOptions); // auth: Authentitation mechanism. Object containing one of: // none: Value "null". Indicates no authorization. // bearer: A bearer token to pass in the `Authorization: Bearer` header on all -// requests. -// basic: A `{username, password}` object. +// requests. Encrypted with nonce 0. +// basic: A `{username, password}` object. The password is encrypted with nonce 0. +// Before encryption, the password is padded to 32 bytes by appending NUL bytes, +// in order to mask the length of small passwords. // refresh: An OAuth refresh token, which can be exchanged for an access token. +// Encrypted with nonce 0. +// TODO(security): How do we protect URLs that directly embed their secret? We don't +// want to encrypt the full URL since this would make it hard to show a +// meaningful audit UI, but maybe we could figure out a way to extract the key +// part and encrypt it separately? // parentToken: If present, then this token represents exactly the capability represented by // the ApiToken with _id = parentToken, except possibly (if it is a UiView) attenuated // by `roleAssignment` (if present). To facilitate permissions computations, if the @@ -441,6 +448,11 @@ ApiTokens = new Mongo.Collection("apiTokens", collectionOptions); // is set to the identity that shared the view, and `accountId` is set to the account // that shared the view. Neither `objectId` nor `frontendRef` is present when // `parentToken` is present. +// parentTokenKey: The actual parent token -- whereas `parentToken` is only the parent token ID +// (hash). `parentTokenFull` is encrypted with nonce 0 (see below). This is needed +// in particular when the parent contains encrypted fields, since those would need to +// be decrypted using this key. If the parent contains no encrypted fields then +// `parentTokenKey` may be omitted from the child. // petname: Human-readable label for this access token, useful for identifying tokens for // revocation. This should be displayed when visualizing incoming capabilities to // the grain identified by `grainId`. @@ -525,6 +537,37 @@ ApiTokens = new Mongo.Collection("apiTokens", collectionOptions); // requirements: List(Supervisor.MembraneRequirement); // ... // } +// +// ENCRYPTION +// +// We want to make sure that someone who obtains a copy of the database cannot use it to gain live +// credentials. +// +// The actual token corresponding to an ApiToken entry is not stored in the entry itself. Instead, +// the ApiToken's `_id` is constructed as a SHA256 hash of the actual token. To use an ApiToken +// in the live system, you must present the original token. +// +// Additionally, some ApiToken entries contain tokens to third-party services, e.g. OAuth tokens +// or even passwords. Such tokens are encrypted, using the ApiToken entry's own full token (which, +// again, is not stored in the database) as the encryption key. +// +// When such encryption is applied, the cipher used is ChaCha20. All API tokens are 256-bit base64 +// strings, hence can be used directly as the key. No MAC is applied, because this scheme is not +// intended to protect against attackers who have write access to the database -- such an attacker +// could almost certainly do more damage by modifying the non-encrypted fields anyway. (Put another +// way, if we wanted to MAC something, we'd needto MAC the entire ApiToken structure, not just +// the encrypted key. But we don't have a way to do that at present.) +// +// ChaCha20 requires a nonce. Luckily, all of the fields we wish to encrypt are immutable, so we +// don't have to worry about tracking nonces over time -- we can just assign a static nonce to each +// field. Moreover, many (currently, all) of these fields are mutually exclusive, so can even share +// nonces. Currently, nonces map to fields as follows: +// +// nonce 0: +// parentTokenKey +// frontendRef.http.auth.basic.password +// frontendRef.http.auth.bearer +// frontendRef.http.auth.refresh ApiTokens.ensureIndexOnServer("grainId", { sparse: 1 }); ApiTokens.ensureIndexOnServer("owner.user.identityId", { sparse: 1 }); From 05375414ab8443b46e90293ffd67acff98bb7e95 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 20:08:46 -0800 Subject: [PATCH 16/22] Add helpers to read and write ApiTokens with encryption applied. --- shell/imports/server/persistent.js | 69 +++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/shell/imports/server/persistent.js b/shell/imports/server/persistent.js index 6947265589..840d9f9b4c 100644 --- a/shell/imports/server/persistent.js +++ b/shell/imports/server/persistent.js @@ -17,6 +17,7 @@ import { inMeteor } from "/imports/server/async-helpers.js"; const Crypto = Npm.require("crypto"); +const Capnp = Npm.require("capnp"); const privateDb = Symbol("PersistentImpl.db"); const privateTemplate = Symbol("PersistentImpl.template"); @@ -92,6 +93,69 @@ function generateSturdyRef() { return Random.secret(); } +function cryptApiToken(key, entry, cryptIn, cryptOut) { + // Encrypts or decrypts all fields of an ApiToken. + // `cryptIn` translates a token in the input to a buffer. + // `cryptOut` translates a buffer to a token in the output. + + check(key, String); + check(entry, Object); + + const nonce0 = new Buffer(8); + nonce0.fill(0); + + const keyBuf = new Buffer(32); + keyBuf.fill(0); + keyBuf.write(key, 0, 32, "base64"); + + function encrypt0(token) { + return cryptOut(Capnp.chacha20(cryptIn(token), nonce0, keyBuf)); + } + + if (entry.parentTokenKey) { + entry.parentTokenKey = encrypt0(entry.parentTokenKey); + } else if (entry.frontendRef && entry.frontendRef.http) { + const http = entry.frontendRef.http; + if (http.auth) { + const auth = http.auth; + if (auth.bearer) { + auth.bearer = encrypt0(auth.bearer); + } else if (auth.basic) { + auth.basic.password = encrypt0(auth.basic.password); + } else if (auth.refresh) { + auth.refresh = encrypt0(auth.refresh); + } + } + } +} + +function fetchApiToken(db, key, moreQuery) { + // Reads an ApiToken from the database and decrypts its encrypted fields. + + const query = { _id: hashSturdyRef(key) }; + Object.assign(query, moreQuery || {}); + + const entry = db.collections.apiTokens.findOne(query); + if (entry) { + cryptApiToken(key, entry, x => x, x => x.toString("utf8")); + } + + return entry; +} + +function insertApiToken(db, entry, key) { + // Adds a new ApiToken to the database. `key`, if specified, *must* be a base64-encoded 256-bit + // value. If omitted, a key will be generated. Either way, the key is returned, and entry._id + // is filled in. Also, as a side effect, some fields of `entry` will become encrypted, but + // ideally callers should not depend on this behavior. + + if (!key) key = generateSturdyRef(); + entry._id = hashSturdyRef(key); + cryptApiToken(key, entry, x => new Buffer(x, "utf8"), x => x); + db.collections.apiTokens.insert(entry); + return key; +} + function checkRequirements(db, requirements) { // Checks if the given list of MembraneRequirements are all satisfied, returning true if so and // false otherwise. @@ -162,4 +226,7 @@ function checkRequirements(db, requirements) { }); }; -export { PersistentImpl, hashSturdyRef, generateSturdyRef, checkRequirements }; +export { + PersistentImpl, hashSturdyRef, generateSturdyRef, checkRequirements, + fetchApiToken, insertApiToken, +}; From 69f9f8c6e40d727c109d923a3752360a743640e5 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 20:09:23 -0800 Subject: [PATCH 17/22] Use new fetchApiToken/insertApiToken helpers to implement encrypted credentials in ApiToken. --- shell/imports/server/persistent.js | 5 +- shell/server/core.js | 82 +++++++++++++++--------------- shell/server/hack-session.js | 16 +++--- 3 files changed, 49 insertions(+), 54 deletions(-) diff --git a/shell/imports/server/persistent.js b/shell/imports/server/persistent.js index 840d9f9b4c..ba698cc7be 100644 --- a/shell/imports/server/persistent.js +++ b/shell/imports/server/persistent.js @@ -71,12 +71,9 @@ class PersistentImpl { newToken.owner.user = userOwner; } - const sturdyRef = generateSturdyRef(); - newToken._id = hashSturdyRef(sturdyRef); - newToken.created = new Date(); + const sturdyRef = insertApiToken(db, newToken); - db.collections.apiTokens.insert(newToken); this[privateIsSaved] = true; return { sturdyRef: new Buffer(sturdyRef) }; }); diff --git a/shell/server/core.js b/shell/server/core.js index e0c4510dfe..62d492d32a 100644 --- a/shell/server/core.js +++ b/shell/server/core.js @@ -18,8 +18,8 @@ import { inMeteor, waitPromise } from "/imports/server/async-helpers.js"; import { StaticAssetImpl, IdenticonStaticAssetImpl } from "/imports/server/static-asset.js"; const Capnp = Npm.require("capnp"); const Crypto = Npm.require("crypto"); -import { PersistentImpl, hashSturdyRef, generateSturdyRef, checkRequirements } - from "/imports/server/persistent.js"; +import { PersistentImpl, hashSturdyRef, generateSturdyRef, checkRequirements, + fetchApiToken, insertApiToken } from "/imports/server/persistent.js"; const PersistentHandle = Capnp.importSystem("sandstorm/supervisor.capnp").PersistentHandle; const SandstormCore = Capnp.importSystem("sandstorm/supervisor.capnp").SandstormCore; @@ -37,12 +37,9 @@ class SandstormCoreImpl { restore(sturdyRef) { return inMeteor(() => { - const hashedSturdyRef = hashSturdyRef(sturdyRef); - const token = this.db.collections.apiTokens.findOne({ - _id: hashedSturdyRef, - "owner.grain.grainId": this.grainId, - }); - + sturdyRef = sturdyRef.toString("utf8"); + const token = fetchApiToken(this.db, sturdyRef, + { "owner.grain.grainId": this.grainId }); if (!token) { throw new Error("no such token"); } @@ -60,16 +57,14 @@ class SandstormCoreImpl { drop(sturdyRef) { return inMeteor(() => { + sturdyRef = sturdyRef.toString("utf8"); return dropInternal(this.db, sturdyRef, { grain: Match.ObjectIncluding({ grainId: this.grainId }) }); }); } makeToken(ref, owner, requirements) { return inMeteor(() => { - const sturdyRef = new Buffer(generateSturdyRef()); - const hashedSturdyRef = hashSturdyRef(sturdyRef); - this.db.collections.apiTokens.insert({ - _id: hashedSturdyRef, + const sturdyRef = insertApiToken(this.db, { grainId: this.grainId, objectId: ref, owner: owner, @@ -78,7 +73,7 @@ class SandstormCoreImpl { }); return { - token: sturdyRef, + token: new Buffer(sturdyRef), }; }); } @@ -272,7 +267,7 @@ function dismissNotification(db, notificationId, callCancel) { if (notification.ongoing) { // For some reason, Mongo returns an object that looks buffer-like, but isn't a buffer. // Only way to fix seems to be to copy it. - const id = new Buffer(notification.ongoing); + const id = notification.ongoing; if (!callCancel) { dropInternal(db, id, { frontend: null }); @@ -351,18 +346,15 @@ Meteor.methods({ throw new Meteor.Error(404, "No matching session found."); } - const tokenId = hashSturdyRef(sturdyRef); - const apiToken = db.collections.apiTokens.findOne({ - _id: tokenId, - "owner.clientPowerboxOffer.sessionId": sessionId, - }); - + const apiToken = fetchApiToken(db, sturdyRef, + { "owner.clientPowerboxOffer.sessionId": sessionId }); if (!apiToken) { throw new Meteor.Error(404, "No such token."); } + const tokenId = apiToken._id; + let newSturdyRef; - let hashedNewSturdyRef; if (sessionToken && apiToken.parentToken) { // An anonymous user is being offered a child token. To avoid bloating the database, // we deterministically derive the sturdyref from the session token and the hashed parent @@ -381,21 +373,18 @@ Meteor.methods({ .slice(0, -1) // removing trailing "=" .replace(/\+/g, "-").replace(/\//g, "_"); // make URL-safe - hashedNewSturdyRef = hashSturdyRef(newSturdyRef); - if (db.collections.apiTokens.findOne({ _id: hashedNewSturdyRef })) { + if (fetchApiToken(db, newSturdyRef)) { // We have already generated this token. db.removeApiTokens({ _id: tokenId }); return newSturdyRef; } } else { newSturdyRef = generateSturdyRef(); - hashedNewSturdyRef = hashSturdyRef(newSturdyRef); } - apiToken._id = hashedNewSturdyRef; apiToken.owner = { webkey: null }; - db.collections.apiTokens.insert(apiToken); + insertApiToken(db, apiToken, newSturdyRef); db.removeApiTokens({ _id: tokenId }); return newSturdyRef; }, @@ -415,7 +404,7 @@ const makeSaveTemplateForChild = function (db, parentToken, requirements, parent // `parentTokenInfo` is the ApiToken record for `parentToken`. Provide this only if you have // it handy; if omitted it will be looked up. - parentTokenInfo = parentTokenInfo || db.collections.apiTokens.findOne(hashSturdyRef(parentToken)); + parentTokenInfo = parentTokenInfo || fetchApiToken(db, parentToken); if (!parentTokenInfo) { throw new Error("no such token"); } @@ -448,6 +437,9 @@ const makeSaveTemplateForChild = function (db, parentToken, requirements, parent // Saved token should be a child of the restored token. saveTemplate.parentToken = parentTokenInfo._id; + + // Will be encrypted on save(). + saveTemplate.parentTokenKey = parentToken; } if (requirements) { @@ -459,7 +451,7 @@ const makeSaveTemplateForChild = function (db, parentToken, requirements, parent }; restoreInternal = (db, originalToken, ownerPattern, requirements, originalTokenInfo, - currentTokenId) => { + currentTokenId, currentTokenKey) => { // Restores the token `originalToken`, which is a Buffer. // // `ownerPattern` is a match pattern (i.e. used with check()) that the token's owner must match. @@ -478,14 +470,15 @@ restoreInternal = (db, originalToken, ownerPattern, requirements, originalTokenI requirements = requirements || []; if (!originalTokenInfo) { - originalTokenInfo = db.collections.apiTokens.findOne(hashSturdyRef(originalToken)); + originalTokenInfo = fetchApiToken(db, originalToken); if (!originalTokenInfo) { throw new Meteor.Error(403, "No token found to restore"); } } - const token = currentTokenId ? - db.collections.apiTokens.findOne(currentTokenId) : originalTokenInfo; + const token = !currentTokenId ? originalTokenInfo : + typeof currentTokenKey === "string" ? fetchApiToken(db, currentTokenKey) : + db.collections.apiTokens.findOne(currentTokenId); if (!token) { if (!originalTokenInfo) { throw new Meteor.Error(403, "Couldn't restore token because parent token has been deleted"); @@ -521,7 +514,7 @@ restoreInternal = (db, originalToken, ownerPattern, requirements, originalTokenI // A token which chains to some parent token. Restore the parent token (possibly recursively), // checking requirements on the way up. return restoreInternal(db, originalToken, Match.Any, requirements, - originalTokenInfo, token.parentToken); + originalTokenInfo, token.parentToken, token.parentTokenKey); } // Check the passed-in `requirements`. @@ -539,7 +532,7 @@ restoreInternal = (db, originalToken, ownerPattern, requirements, originalTokenI return waitPromise(globalBackend.useGrain(token.grainId, (supervisor) => { // Note that in this case it is the supervisor's job to implement SystemPersistent, so we // don't generate a saveTemplate here. - return supervisor.restore(token.objectId, requirements, originalToken); + return supervisor.restore(token.objectId, requirements, new Buffer(originalToken, "utf8")); })); } else { // Construct a template ApiToken for use if the restored capability is save()d later. @@ -567,12 +560,13 @@ restoreInternal = (db, originalToken, ownerPattern, requirements, originalTokenI function dropInternal(db, sturdyRef, ownerPattern) { // Drops `sturdyRef`, checking first that its owner matches `ownerPattern`. - const hashedSturdyRef = hashSturdyRef(sturdyRef); - const token = db.collections.apiTokens.findOne({ _id: hashedSturdyRef }); + const token = fetchApiToken(db, sturdyRef); if (!token) { return; } + const hashedSturdyRef = token._id; + if (!Match.test(token.owner, ownerPattern)) { // The caller of `drop()` does not own the token. From the caller's perspective, this means // that there is actually nothing to be dropped. For example, perhaps a grain got backed up @@ -581,10 +575,13 @@ function dropInternal(db, sturdyRef, ownerPattern) { return; } + // TODO(soon): Revoke OAuth tokens. + if (token.frontendRef && token.frontendRef.notificationHandle) { const notificationId = token.frontendRef.notificationHandle; db.removeApiTokens({ _id: hashedSturdyRef }); - const anyToken = db.collections.apiTokens.findOne({ "frontendRef.notificationHandle": notificationId }); + const anyToken = db.collections.apiTokens.findOne( + { "frontendRef.notificationHandle": notificationId }); if (!anyToken) { // No other tokens referencing this notification exist, so dismiss the notification dismissNotification(db, notificationId); @@ -632,16 +629,21 @@ unwrapFrontendCap = (cap, type, callback) => { return cap.castAs(SystemPersistent).save({ frontend: null }).then(saveResult => { return inMeteor(() => { - const tokenId = hashSturdyRef(saveResult.sturdyRef); - let tokenInfo = ApiTokens.findOne(tokenId); + let tokenInfo = fetchApiToken(globalDb, saveResult.sturdyRef); // Delete the token now since it's not needed. - ApiTokens.remove(tokenId); + ApiTokens.remove(tokenInfo._id); for (;;) { if (!tokenInfo) throw new Error("missing token?"); if (!tokenInfo.parentToken) break; - tokenInfo = ApiTokens.findOne(tokenInfo.parentToken); + if (typeof tokenInfo.parentTokenKey === "string") { + tokenInfo = fetchApiToken(globalDb, tokenInfo.parentTokenKey); + } else { + // Hmm, parentTokenKey doesn't exist or is still encrypted. We can't decrypt the parent + // but we can still fetch it in encrypted format. + tokenInfo = ApiTokens.findOne(tokenInfo.parentToken); + } } if (!tokenInfo.frontendRef || !tokenInfo.frontendRef[type]) { diff --git a/shell/server/hack-session.js b/shell/server/hack-session.js index feda062278..d1b38333dd 100644 --- a/shell/server/hack-session.js +++ b/shell/server/hack-session.js @@ -20,7 +20,7 @@ const Https = Npm.require("https"); const Net = Npm.require("net"); const Dgram = Npm.require("dgram"); const Capnp = Npm.require("capnp"); -import { hashSturdyRef, checkRequirements } from "/imports/server/persistent.js"; +import { hashSturdyRef, checkRequirements, fetchApiToken } from "/imports/server/persistent.js"; import { inMeteor, waitPromise } from "/imports/server/async-helpers.js"; const EmailRpc = Capnp.importSystem("sandstorm/email.capnp"); @@ -47,12 +47,8 @@ SessionContextImpl = class SessionContextImpl { claimRequest(sturdyRef, requiredPermissions) { return inMeteor(() => { - const hashedSturdyRef = hashSturdyRef(sturdyRef); - - const token = ApiTokens.findOne({ - _id: hashedSturdyRef, - "owner.clientPowerboxRequest.sessionId": this.sessionId, - }); + const token = fetchApiToken(globalDb, sturdyRef, + { "owner.clientPowerboxRequest.sessionId": this.sessionId }); if (!token) { throw new Error("no such token"); @@ -189,10 +185,10 @@ SessionContextImpl = class SessionContextImpl { } else if (isUiView) { if (session.identityId) { // Deduplicate. - let tokenId = hashSturdyRef(sturdyRef.toString()); - const newApiToken = ApiTokens.findOne({ _id: tokenId }); + const newApiToken = fetchApiToken(globalDb, sturdyRef.toString()); + let tokenId = newApiToken._id; const dupeQuery = _.pick(newApiToken, "grainId", "roleAssignment", "requirements", - "parentToken", "identityId", "accountId"); + "parentToken", "parentTokenKey", "identityId", "accountId"); dupeQuery._id = { $ne: newApiToken._id }; dupeQuery["owner.user.identityId"] = this.identityId; dupeQuery.trashed = { $exists: false }; From 33cdbb94f57c77874e5c7e16a3fdd48a26c50b28 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 20:31:44 -0800 Subject: [PATCH 18/22] Pad short secrets to 32 bytes, per the documentation of http.auth.basic.password. --- shell/imports/server/persistent.js | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/shell/imports/server/persistent.js b/shell/imports/server/persistent.js index ba698cc7be..d7c9068b7d 100644 --- a/shell/imports/server/persistent.js +++ b/shell/imports/server/persistent.js @@ -129,12 +129,20 @@ function cryptApiToken(key, entry, cryptIn, cryptOut) { function fetchApiToken(db, key, moreQuery) { // Reads an ApiToken from the database and decrypts its encrypted fields. + function bufferToString(buf) { + // un-pad short secrets + let size = buf.length; + while (size > 0 && buf[size-1] == 0) { + --size; + } + return buf.slice(0, size).toString("utf8"); + } + const query = { _id: hashSturdyRef(key) }; Object.assign(query, moreQuery || {}); - const entry = db.collections.apiTokens.findOne(query); if (entry) { - cryptApiToken(key, entry, x => x, x => x.toString("utf8")); + cryptApiToken(key, entry, x => x, bufferToString); } return entry; @@ -146,9 +154,19 @@ function insertApiToken(db, entry, key) { // is filled in. Also, as a side effect, some fields of `entry` will become encrypted, but // ideally callers should not depend on this behavior. + function stringToBuffer(str) { + const buf = new Buffer(str, "utf8"); + if (buf.length >= 32) return buf; + + const padded = new Buffer(32); + padded.fill(0); + buf.copy(padded); + return padded; + } + if (!key) key = generateSturdyRef(); entry._id = hashSturdyRef(key); - cryptApiToken(key, entry, x => new Buffer(x, "utf8"), x => x); + cryptApiToken(key, entry, stringToBuffer, x => x); db.collections.apiTokens.insert(entry); return key; } From 9b4df641cb78f34434768bcb127acd50b635db41 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 20:38:18 -0800 Subject: [PATCH 19/22] jscs --fix --- shell/imports/server/persistent.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell/imports/server/persistent.js b/shell/imports/server/persistent.js index d7c9068b7d..516632bdbc 100644 --- a/shell/imports/server/persistent.js +++ b/shell/imports/server/persistent.js @@ -132,9 +132,10 @@ function fetchApiToken(db, key, moreQuery) { function bufferToString(buf) { // un-pad short secrets let size = buf.length; - while (size > 0 && buf[size-1] == 0) { + while (size > 0 && buf[size - 1] == 0) { --size; } + return buf.slice(0, size).toString("utf8"); } From 68d83daf7d5e70cdb7ed85073d137c1917a4feb9 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Thu, 16 Feb 2017 21:07:30 -0800 Subject: [PATCH 20/22] Fix bugs caught by tests. Main problem is that various methods that previously accepted either a String or a Buffer as a SturdyRef representation are now more strict. I wish we had a type system. --- shell/server/core.js | 6 ++---- shell/server/hack-session.js | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/shell/server/core.js b/shell/server/core.js index 62d492d32a..ae03cf6e5c 100644 --- a/shell/server/core.js +++ b/shell/server/core.js @@ -103,7 +103,7 @@ class SandstormCoreImpl { } const castedNotification = notification.castAs(PersistentOngoingNotification); - const wakelockToken = waitPromise(castedNotification.save()).sturdyRef; + const wakelockToken = waitPromise(castedNotification.save()).sturdyRef.toString("utf8"); // We have to close both the casted cap and the original. Perhaps this should be fixed in // node-capnp? @@ -265,8 +265,6 @@ function dismissNotification(db, notificationId, callCancel) { if (notification) { db.collections.notifications.remove({ _id: notificationId }); if (notification.ongoing) { - // For some reason, Mongo returns an object that looks buffer-like, but isn't a buffer. - // Only way to fix seems to be to copy it. const id = notification.ongoing; if (!callCancel) { @@ -629,7 +627,7 @@ unwrapFrontendCap = (cap, type, callback) => { return cap.castAs(SystemPersistent).save({ frontend: null }).then(saveResult => { return inMeteor(() => { - let tokenInfo = fetchApiToken(globalDb, saveResult.sturdyRef); + let tokenInfo = fetchApiToken(globalDb, saveResult.sturdyRef.toString("utf8")); // Delete the token now since it's not needed. ApiTokens.remove(tokenInfo._id); diff --git a/shell/server/hack-session.js b/shell/server/hack-session.js index d1b38333dd..2b18b20edc 100644 --- a/shell/server/hack-session.js +++ b/shell/server/hack-session.js @@ -90,8 +90,7 @@ SessionContextImpl = class SessionContextImpl { } return restoreInternal( - globalDb, - new Buffer(sturdyRef), + globalDb, sturdyRef, { clientPowerboxRequest: Match.ObjectIncluding({ sessionId: this.sessionId }) }, requirements, token); }); @@ -273,7 +272,7 @@ Meteor.methods({ throw new Meteor.Error(400, "Invalid webkey: token doesn't match hostname."); } - const cap = restoreInternal(db, new Buffer(token), + const cap = restoreInternal(db, token, Match.Optional({ webkey: Match.Optional(Match.Any) }), []).cap; const castedCap = cap.castAs(SystemPersistent); const owner = { From 765829212a829efb342c1e5d6f3a9c653121070a Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Fri, 17 Feb 2017 11:45:12 -0800 Subject: [PATCH 21/22] typo --- shell/packages/sandstorm-db/db.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell/packages/sandstorm-db/db.js b/shell/packages/sandstorm-db/db.js index 4418727935..2294740a78 100644 --- a/shell/packages/sandstorm-db/db.js +++ b/shell/packages/sandstorm-db/db.js @@ -555,7 +555,7 @@ ApiTokens = new Mongo.Collection("apiTokens", collectionOptions); // strings, hence can be used directly as the key. No MAC is applied, because this scheme is not // intended to protect against attackers who have write access to the database -- such an attacker // could almost certainly do more damage by modifying the non-encrypted fields anyway. (Put another -// way, if we wanted to MAC something, we'd needto MAC the entire ApiToken structure, not just +// way, if we wanted to MAC something, we'd need to MAC the entire ApiToken structure, not just // the encrypted key. But we don't have a way to do that at present.) // // ChaCha20 requires a nonce. Luckily, all of the fields we wish to encrypt are immutable, so we From 60d3b87ee1413d36cb3c044c58b5b192a91f76fc Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Mon, 27 Feb 2017 15:39:36 -0800 Subject: [PATCH 22/22] Typos spotted by @ocdtrekkie --- shell/packages/sandstorm-db/db.js | 2 +- shell/server/drivers/external-ui-view.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shell/packages/sandstorm-db/db.js b/shell/packages/sandstorm-db/db.js index 2294740a78..e3cf52ae39 100644 --- a/shell/packages/sandstorm-db/db.js +++ b/shell/packages/sandstorm-db/db.js @@ -428,7 +428,7 @@ ApiTokens = new Mongo.Collection("apiTokens", collectionOptions); // identity: An Identity capability. The field is the identity ID. // http: An ApiSession capability pointing to an external HTTP service. Object containing: // url: Base URL of the external service. -// auth: Authentitation mechanism. Object containing one of: +// auth: Authentication mechanism. Object containing one of: // none: Value "null". Indicates no authorization. // bearer: A bearer token to pass in the `Authorization: Bearer` header on all // requests. Encrypted with nonce 0. diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js index ab0a3a15fb..cb5a1585b4 100644 --- a/shell/server/drivers/external-ui-view.js +++ b/shell/server/drivers/external-ui-view.js @@ -164,7 +164,7 @@ function registerHttpApiFrontendRef(registry) { if ("none" in request.auth) { request.auth = { bearer: parsedUrl.hash.slice(1) }; } else { - throw new Meteor.Error(400, "Can't supprot multiple authentication mechanisms at once"); + throw new Meteor.Error(400, "Can't support multiple authentication mechanisms at once"); } parsedUrl.hash = null;