const ObservableStore = require('obs-store') const SafeEventEmitter = require('safe-event-emitter') const createAsyncMiddleware = require('json-rpc-engine/src/createAsyncMiddleware') const { errors: rpcErrors } = require('eth-json-rpc-errors') /** * A controller that services user-approved requests for a full Ethereum provider API */ class ProviderApprovalController extends SafeEventEmitter { /** * Creates a ProviderApprovalController * * @param {Object} [config] - Options to configure controller */ constructor ({ closePopup, initState, keyringController, openPopup, preferencesController } = {}) { super() this.closePopup = closePopup this.keyringController = keyringController this.openPopup = openPopup this.preferencesController = preferencesController this.memStore = new ObservableStore({ providerRequests: [], }) const defaultState = { approvedOrigins: {} } this.store = new ObservableStore(Object.assign(defaultState, initState)) } /** * Called when a user approves access to a full Ethereum provider API * * @param {object} opts - opts for the middleware contains the origin for the middleware */ createMiddleware ({ senderUrl, extensionId, getSiteMetadata }) { return createAsyncMiddleware(async (req, res, next) => { // only handle requestAccounts if (req.method !== 'eth_requestAccounts') return next() // if already approved or privacy mode disabled, return early const isUnlocked = this.keyringController.memStore.getState().isUnlocked const origin = senderUrl.hostname if (this.shouldExposeAccounts(origin) && isUnlocked) { res.result = [this.preferencesController.getSelectedAddress()] return } // register the provider request const metadata = { hostname: senderUrl.hostname, origin } if (extensionId) { metadata.extensionId = extensionId } else { Object.assign(metadata, await getSiteMetadata(origin)) } this._handleProviderRequest(metadata) // wait for resolution of request const approved = await new Promise(resolve => this.once(`resolvedRequest:${origin}`, ({ approved }) => resolve(approved))) if (approved) { res.result = [this.preferencesController.getSelectedAddress()] } else { throw rpcErrors.eth.userRejectedRequest('User denied account authorization') } }) } /** * @typedef {Object} SiteMetadata * @param {string} hostname - The hostname of the site * @param {string} origin - The origin of the site * @param {string} [siteTitle] - The title of the site * @param {string} [siteImage] - The icon for the site * @param {string} [extensionId] - The extension ID of the extension */ /** * Called when a tab requests access to a full Ethereum provider API * * @param {SiteMetadata} siteMetadata - The metadata for the site requesting full provider access */ _handleProviderRequest (siteMetadata) { const { providerRequests } = this.memStore.getState() const origin = siteMetadata.origin this.memStore.updateState({ providerRequests: [ ...providerRequests, siteMetadata, ], }) const isUnlocked = this.keyringController.memStore.getState().isUnlocked const { approvedOrigins } = this.store.getState() const originAlreadyHandled = approvedOrigins[origin] if (originAlreadyHandled && isUnlocked) { return } this.openPopup && this.openPopup() } /** * Called when a user approves access to a full Ethereum provider API * * @param {string} origin - origin of the domain that had provider access approved */ approveProviderRequestByOrigin (origin) { if (this.closePopup) { this.closePopup() } const { approvedOrigins } = this.store.getState() const { providerRequests } = this.memStore.getState() const providerRequest = providerRequests.find((request) => request.origin === origin) const remainingProviderRequests = providerRequests.filter(request => request.origin !== origin) this.store.updateState({ approvedOrigins: { ...approvedOrigins, [origin]: { siteTitle: providerRequest ? providerRequest.siteTitle : null, siteImage: providerRequest ? providerRequest.siteImage : null, hostname: providerRequest ? providerRequest.hostname : null, }, }, }) this.memStore.updateState({ providerRequests: remainingProviderRequests }) this.emit(`resolvedRequest:${origin}`, { approved: true }) } /** * Called when a tab rejects access to a full Ethereum provider API * * @param {string} origin - origin of the domain that had provider access approved */ rejectProviderRequestByOrigin (origin) { if (this.closePopup) { this.closePopup() } const { approvedOrigins } = this.store.getState() const { providerRequests } = this.memStore.getState() const remainingProviderRequests = providerRequests.filter(request => request.origin !== origin) // We're cloning and deleting keys here because we don't want to keep unneeded keys const _approvedOrigins = Object.assign({}, approvedOrigins) delete _approvedOrigins[origin] this.store.putState({ approvedOrigins: _approvedOrigins }) this.memStore.putState({ providerRequests: remainingProviderRequests }) this.emit(`resolvedRequest:${origin}`, { approved: false }) } /** * Clears any approvals for user-approved origins */ clearApprovedOrigins () { this.store.updateState({ approvedOrigins: {}, }) } /** * Determines if a given origin should have accounts exposed * * @param {string} origin - Domain origin to check for approval status * @returns {boolean} - True if the origin has been approved */ shouldExposeAccounts (origin) { return Boolean(this.store.getState().approvedOrigins[origin]) } /** * Returns a merged state representation * @return {object} * @private */ _getMergedState () { return Object.assign({}, this.memStore.getState(), this.store.getState()) } } module.exports = ProviderApprovalController