1 | import { Credentials, ContractFactory } from 'uport-credentials'
|
2 | import { verifyJWT, decodeJWT } from 'did-jwt'
|
3 | import MobileDetect from 'mobile-detect'
|
4 | import { isMNID, encode, decode } from 'mnid'
|
5 | import { transport, message, network } from 'uport-transports'
|
6 | import PubSub from 'pubsub-js'
|
7 | import store from 'store'
|
8 | import UportLite from 'uport-lite'
|
9 |
|
10 | import { isMobile, ipfsAdd } from './util'
|
11 | import UportSubprovider from './UportSubprovider'
|
12 |
|
13 | class Connect {
|
14 | /**
|
15 | * Instantiates a new uPort Connect object.
|
16 | *
|
17 | * @example
|
18 | * import Connect from 'uport-connect'
|
19 | * const connect = new Connect('MydappName')
|
20 | *
|
21 | * @param {String} appName The name of your app
|
22 | * @param {Object} [opts] optional parameters
|
23 | * @param {String} [opts.description] A short description of your app that can be displayed to users when making requests
|
24 | * @param {String} [opts.profileImage] A URL for an image to be displayed as the avatar for this app in requests
|
25 | * @param {String} [opts.bannerImage] A URL for an image to be displayed as the banner for this app in requests
|
26 | * @param {String} [opts.brandColor] A HEX brand color to be be displayed where branding is required '#000000'
|
27 | * @param {Object} [opts.network='rinkeby'] network config object or string name, ie. { id: '0x1', rpcUrl: 'https://mainnet.infura.io' } or 'kovan', 'mainnet', 'ropsten', 'rinkeby'.
|
28 | * @param {String} [opts.accountType] Ethereum account type: "general", "segregated", "keypair", or "none"
|
29 | * @param {Boolean} [opts.isMobile] Configured by default by detecting client, but can optionally pass boolean to indicate whether this is instantiated on a mobile client
|
30 | * @param {Boolean} [opts.useStore=true] When true, object state will be written to local storage on each state change
|
31 | * @param {Object} [opts.store] Storage inteferface with synchronous get() => statObj and set(stateObj) functions, by default store is local storage. For asynchronous storage, set useStore false and handle manually.
|
32 | * @param {Boolean} [opts.usePush=true] Use the pushTransport when a pushToken is available. Set to false to force connect to use standard transport
|
33 | * @param {String[]} [opts.vc] An array of verified claims describing this identity
|
34 | * @param {Function} [opts.transport] Optional custom transport for desktop, non-push requests
|
35 | * @param {Function} [opts.mobileTransport] Optional custom transport for mobile requests
|
36 | * @param {Function} [opts.mobileUriHandler] Optional uri handler for mobile requests, if using default transports
|
37 | * @param {Object} [opts.muportConfig] Configuration object for muport did resolver. See [muport-did-resolver](https://github.com/uport-project/muport-did-resolver)
|
38 | * @param {Object} [opts.ethrConfig] Configuration object for ethr did resolver. See [ethr-did-resolver](https://github.com/uport-project/ethr-did-resolver)
|
39 | * @param {Object} [opts.registry] Configuration for uPort DID Resolver (DEPRECATED) See [uport-did-resolver](https://github.com/uport-project/uport-did-resolver)
|
40 | * @return {Connect} self
|
41 | */
|
42 | constructor (appName, opts = {}) {
|
43 | // Config
|
44 | this.appName = appName || 'uport-connect-app'
|
45 | this.description = opts.description
|
46 | this.profileImage = opts.profileImage
|
47 | this.bannerImage = opts.bannerImage
|
48 | this.brandColor = opts.brandColor
|
49 | this.network = network.config.network(opts.network)
|
50 | this.accountType = opts.accountType === 'none' ? undefined : opts.accountType
|
51 | this.isOnMobile = opts.isMobile === undefined ? isMobile() : opts.isMobile
|
52 | this.useStore = opts.useStore === undefined ? true : opts.useStore
|
53 | this.usePush = opts.usePush === undefined ? true : opts.usePush
|
54 | this.vc = Array.isArray(opts.vc) ? opts.vc : []
|
55 |
|
56 | // Disallow segregated account on mainnet
|
57 | if (this.network === network.defaults.networks.mainnet && this.accountType === 'segregated') {
|
58 | throw new Error('Segregated accounts are not supported on mainnet')
|
59 | }
|
60 |
|
61 | // Storage
|
62 | this.store = opts.store || new LocalStorageStore()
|
63 |
|
64 | // Initialize private state
|
65 | this._state = {}
|
66 |
|
67 | // Load any existing state if any
|
68 | if (this.useStore) this.loadState()
|
69 | if (!this.keypair.did) this.keypair = Credentials.createIdentity()
|
70 |
|
71 | // Transports
|
72 | this.PubSub = PubSub
|
73 | this.transport = opts.transport || connectTransport(appName)
|
74 | this.useDeeplinks = true
|
75 | this.mobileTransport = opts.mobileTransport || transport.url.send({
|
76 | uriHandler: opts.mobileUriHandler,
|
77 | messageToURI: (m) => this.useDeeplinks ? message.util.messageToDeeplinkURI(m) : message.util.messageToUniversalURI(m)
|
78 | })
|
79 | this.onloadResponse = opts.onloadResponse || transport.url.getResponse()
|
80 | this.pushTransport = (this.pushToken && this.publicEncKey) ? pushTransport(this.pushToken, this.publicEncKey) : undefined
|
81 | transport.url.listenResponse((err, res) => {
|
82 | if (err) throw err
|
83 | // Switch to deep links after first universal link success
|
84 | this.useDeeplinks = true
|
85 | this.pubResponse(res)
|
86 | })
|
87 |
|
88 | // Credential (uport-js) config for verification
|
89 | this.registry = opts.registry || UportLite({ networks: network.config.networkToNetworkSet(this.network) })
|
90 | this.resolverConfigs = {registry: this.registry, ethrConfig: opts.ethrConfig, muportConfig: opts.muportConfig }
|
91 | this.credentials = new Credentials(Object.assign(this.keypair, this.resolverConfigs)) // TODO can resolver configs not be passed through
|
92 | }
|
93 |
|
94 | /**
|
95 | * Instantiates and returns a web3 styple provider wrapped with uPort functionality.
|
96 | * For more details see uportSubprovider. uPort overrides eth_coinbase and eth_accounts
|
97 | * to start a get address flow or to return an already received address. It also
|
98 | * overrides eth_sendTransaction to start the send transaction flow to pass the
|
99 | * transaction to the uPort app.
|
100 | *
|
101 | * @example
|
102 | * const uportProvider = connect.getProvider()
|
103 | * const web3 = new Web3(uportProvider)
|
104 | *
|
105 | * @param {Object} [provider] An optional web3 style provider to wrap, default is a http provider, non standard provider may cause unexpected behavior, using default is suggested.
|
106 | * @return {UportSubprovider} A web3 style provider wrapped with uPort functionality
|
107 | */
|
108 | getProvider (provider) {
|
109 | const subProvider = new UportSubprovider({
|
110 | requestAddress: () => {
|
111 | const requestID = 'addressReqProvider'
|
112 | this.requestDisclosure({accountType: this.accountType || 'keypair'}, requestID)
|
113 | return this.onResponse(requestID).then(res => res.payload.address)
|
114 | },
|
115 | sendTransaction: (txObj) => {
|
116 | delete txObj['from']
|
117 | const requestID = 'txReqProvider'
|
118 | this.sendTransaction(txObj, requestID)
|
119 | return this.onResponse(requestID).then(res => res.payload)
|
120 | },
|
121 | signTypedData: (typedData) => {
|
122 | const requestID = 'typedDataSigReqProvider'
|
123 | this.requestTypedDataSignature(typedData, requestID)
|
124 | return this.onResponse(requestID).then(res => res.payload)
|
125 | },
|
126 | personalSign: (data) => {
|
127 | const requestID = 'personalSignReqProvider'
|
128 | this.requestPersonalSign(data, requestID)
|
129 | return this.onResponse(requestID).then(res => res.payload)
|
130 | },
|
131 | provider, network: this.network
|
132 | })
|
133 | if (this.address) subProvider.setAccount(this.address)
|
134 | return subProvider
|
135 | }
|
136 |
|
137 | /**
|
138 | * Get response by id of earlier request, returns promise which resolves when first
|
139 | * reponse with given id is available. If looking for multiple responses of same id,
|
140 | * listen instead by passing a callback.
|
141 | *
|
142 | * @param {String} id id of request you are waiting for a response for
|
143 | * @param {Function} cb an optional callback function, which is called each time a valid repsonse for a given id is available vs having a single promise returned
|
144 | * @return {Promise<Object, Error>} promise resolves once valid response for given id is avaiable, otherwise rejects with error, no promised returned if callback given
|
145 | */
|
146 | onResponse(id, cb) {
|
147 | const parseResponse = (res) => {
|
148 | if (res.error) return Promise.reject(Object.assign({id}, res))
|
149 | if (message.util.isJWT(res.payload)) {
|
150 | const jwt = res.payload
|
151 | const decoded = decodeJWT(jwt)
|
152 | if (decoded.payload.claim){
|
153 | return Promise.resolve(Object.assign({id}, res))
|
154 | }
|
155 | return this.verifyResponse(jwt).then(parsedRes => {
|
156 | // Set identifiers present in the response
|
157 | // TODO maybe just change name in uport-js
|
158 | if (parsedRes.boxPub) parsedRes.publicEncKey = parsedRes.boxPub
|
159 | this.setState(parsedRes)
|
160 | return {id, payload: parsedRes, data: res.data}
|
161 | })
|
162 | } else {
|
163 | return Promise.resolve(Object.assign({id}, res))
|
164 | }
|
165 | }
|
166 |
|
167 | if (this.onloadResponse && this.onloadResponse.id === id) {
|
168 | const onloadResponse = this.onloadResponse
|
169 | this.onloadResponse = null
|
170 | return parseResponse(onloadResponse)
|
171 | }
|
172 |
|
173 | if (cb) {
|
174 | this.PubSub.subscribe(id, (msg, res) => {
|
175 | parseResponse(res).then(
|
176 | (res) => { cb(null, res) },
|
177 | (err) => { cb(err, null) }
|
178 | )
|
179 | })
|
180 | } else {
|
181 | return new Promise((resolve, reject) => {
|
182 | this.PubSub.subscribe(id, (msg, res) => {
|
183 | this.PubSub.unsubscribe(id)
|
184 | parseResponse(res).then(resolve, reject)
|
185 | })
|
186 | })
|
187 | }
|
188 | }
|
189 |
|
190 | /**
|
191 | * Push a response payload to uPort connect to be handled. Useful if implementing your own transports
|
192 | * and you are getting responses with your own functions, listeners, event handlers etc. It will
|
193 | * parse the response and resolve it to any listening onResponse functions with the matching id. A
|
194 | * response object in connect is of the form {id, payload, data}, where payload and id required. Payload is the
|
195 | * response payload (url or JWT) from a uPort client.
|
196 | *
|
197 | * @param {Object} response a wrapped response payload, of form {id, res, data}, res and id required
|
198 | */
|
199 | pubResponse (response) {
|
200 | if (!response || !response.id) throw new Error('Response payload requires an id')
|
201 | this.PubSub.publish(response.id, {payload: response.payload, data: response.data})
|
202 | }
|
203 |
|
204 | /**
|
205 | * @private
|
206 | * Verify a jwt and save the resulting doc to this instance, then process the
|
207 | * disclosure payload with this.credentials
|
208 | * @param {JWT} token the JWT to be verified
|
209 | */
|
210 | verifyResponse (token) {
|
211 | return verifyJWT(token, {audience: this.credentials.did}).then(res => {
|
212 | this.doc = res.doc
|
213 | return this.credentials.processDisclosurePayload(res)
|
214 | })
|
215 | }
|
216 |
|
217 | /**
|
218 | * Send a request message to a uPort client. Useful function if you want to pass additional transport options and/or send a request you already created elsewhere.
|
219 | *
|
220 | * @param {String} request a request message to send to a uPort client
|
221 | * @param {String} id id of the request, which you will later use to handle the response
|
222 | * @param {Object} [opts] optional parameters for a callback, see (specs for more details)[https://github.com/uport-project/specs/blob/develop/messages/index.md]
|
223 | * @param {String} opts.redirectUrl If on mobile client, the url you want the uPort client to return control to once it completes it's flow. Depending on the params below, this redirect can include the response or it may be returned to the callback in the request token.
|
224 | * @param {String} opts.data A string of any data you want later returned with the response. It may be contextual to the original request. (i.e. a request id from your server)
|
225 | * @param {String} opts.type Type specifies the callback action. 'post' to send response to callback in request token or 'redirect' to send response in redirect url.
|
226 | * @param {Function} opts.cancel When using the default QR send, but handling the response yourself, this function will be called when a user closes the request modal.
|
227 | */
|
228 | send (request, id, {redirectUrl, data, type, cancel} = {}) {
|
229 | if (!id) throw new Error('Requires request id')
|
230 | if (this.isOnMobile) {
|
231 | if (!redirectUrl & !type) type = 'redirect'
|
232 | this.mobileTransport(request, {id, data, redirectUrl, type})
|
233 | } else if (this.usePush && this.pushTransport) {
|
234 | this.pushTransport(request, {data}).then(res => this.PubSub.publish(id, res))
|
235 | } else {
|
236 | this.transport(request, {data, cancel}).then(res => this.PubSub.publish(id, res))
|
237 | }
|
238 | }
|
239 |
|
240 | /**
|
241 | * Builds and returns a contract object which can be used to interact with
|
242 | * a given contract. Similar to web3.eth.contract. Once specifying .at(address)
|
243 | * you can call the contract functions with this object. It will create a transaction
|
244 | * sign request and send it. Functionality limited to function calls which require sending
|
245 | * a transaction, as these are the only calls which require interaction with a uPort client.
|
246 | * For reading and/or events use web3 alongside or a similar library.
|
247 | *
|
248 | * @example
|
249 | * const abi = [{"constant":false,"inputs":[{"name":"status","type":"string"}],"name":"updateStatus","outputs":[],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"addr","type":"address"}],"name":"getStatus","outputs":[{"name":"","type":"string"}],"payable":false,"type":"function"}]
|
250 | * const StatusContract = connect.contract(abi).at('0x70A804cCE17149deB6030039798701a38667ca3B')
|
251 | * const reqId = 'updateStatus'
|
252 | * StatusContract.updateStatus('helloStatus', reqId)
|
253 | * connect.onResponse('reqId').then(res => {
|
254 | * const txHash = res.payload
|
255 | * })
|
256 | *
|
257 | * @param {Object} abi contract ABI
|
258 | * @return {Object} contract object
|
259 | */
|
260 | contract (abi) {
|
261 | const txObjHandler = (txObj, id, sendOpts) => {
|
262 | txObj.fn = txObj.function
|
263 | delete txObj['function']
|
264 | return this.sendTransaction(txObj, id, sendOpts)
|
265 | }
|
266 | return ContractFactory(txObjHandler.bind(this))(abi)
|
267 | }
|
268 |
|
269 | /**
|
270 | * Given a transaction object (similarly defined as the web3 transaction object)
|
271 | * it creates a transaction request and sends it. A transaction hash is later
|
272 | * returned as the response if the user selected to sign it.
|
273 | *
|
274 | * @example
|
275 | * const txobject = {
|
276 | * to: '0xc3245e75d3ecd1e81a9bfb6558b6dafe71e9f347',
|
277 | * value: '0.1',
|
278 | * fn: "setStatus(string 'hello', bytes32 '0xc3245e75d3ecd1e81a9bfb6558b6dafe71e9f347')",
|
279 | * appName: 'MyDapp'
|
280 | * }
|
281 | * connect.sendTransaction(txobject, 'setStatus')
|
282 | * connect.onResponse('setStatus').then(res => {
|
283 | * const txHash = res.payload
|
284 | * })
|
285 | *
|
286 | * @param {Object} txObj
|
287 | * @param {String} [id='txReq'] string to identify request, later used to get response, name of function call is used by default, if not a function call, the default is 'txReq'
|
288 | * @param {Object} [sendOpts] reference send function options
|
289 | */
|
290 | async sendTransaction (txObj = {}, id, sendOpts) {
|
291 | if (!txObj.vc) await this.signAndUploadProfile()
|
292 | txObj = {
|
293 | vc: this.vc, ...txObj,
|
294 | to: isMNID(txObj.to) ? txObj.to : encode({network: this.network.id, address: txObj.to}),
|
295 | rpcUrl: this.network.rpcUrl,
|
296 | }
|
297 |
|
298 | // Create default id, where id is function name, or txReq if no function name
|
299 | if (!id) id = txObj.fn ? txObj.fn.split('(')[0] : 'txReq'
|
300 | this.credentials.createTxRequest(txObj, {callbackUrl: this.genCallback(id)})
|
301 | .then(jwt => this.send(jwt, id, sendOpts))
|
302 | }
|
303 |
|
304 | /**
|
305 | * Creates and sends a request for a user to [sign a verification](https://github.com/uport-project/specs/blob/develop/messages/verificationreq.md) and sends the request to the uPort user.
|
306 | *
|
307 | * @example
|
308 | * const unsignedClaim = {
|
309 | * claim: {
|
310 | * "Citizen of city X": {
|
311 | * "Allowed to vote": true,
|
312 | * "Document": "QmZZBBKPS2NWc6PMZbUk9zUHCo1SHKzQPPX4ndfwaYzmPW"
|
313 | * }
|
314 | * },
|
315 | * sub: "did:ethr:0x413daa771a2fc9c5ae5a66abd144881ef2498c54"
|
316 | * }
|
317 | * connect.requestVerificationSignature(unsignedClaim).then(jwt => {
|
318 | * ...
|
319 | * })
|
320 | *
|
321 | * @param {Object} unsignedClaim unsigned claim object which you want the user to attest
|
322 | * @param {String} sub the DID which the unsigned claim is about
|
323 | * @param {String} [id='signVerReq'] string to identify request, later used to get response
|
324 | * @param {Object} [sendOpts] reference send function options
|
325 | */
|
326 | async requestVerificationSignature (unsignedClaim, opts, id = 'verSigReq', sendOpts) {
|
327 | await this.signAndUploadProfile()
|
328 | if (typeof opts === 'string') {
|
329 | console.warn('The subject argument is deprecated, use option object with {sub: sub, ...}')
|
330 | opts = {sub: opts}
|
331 | } else if (!opts || !opts.sub) {
|
332 | throw new Error(`Missing required field sub in opts. Received: ${opts}`)
|
333 | }
|
334 |
|
335 | this.credentials.createVerificationSignatureRequest(unsignedClaim, {...opts, aud: this.did, callbackUrl: this.genCallback(id), vc: this.vc})
|
336 | .then(jwt => this.send(jwt, id, sendOpts))
|
337 | }
|
338 |
|
339 | /**
|
340 | * Creates and sends a request to a user to sign a piece of ERC712 Typed Data
|
341 | *
|
342 | * @param {Object} typedData an object containing unsigned typed, structured data that follows the ERC712 specification
|
343 | * @param {String} [id='typedDataSigReq'] string to identify request, later used to get response
|
344 | * @param {Object} [sendOpts] reference send function options
|
345 | */
|
346 | requestTypedDataSignature (typedData, id = 'typedDataSigReq', sendOpts) {
|
347 | const opts = {
|
348 | callback: this.genCallback(id),
|
349 | net: this.network.id
|
350 | }
|
351 | if (this.address) opts.from = this.address
|
352 | this.credentials.createTypedDataSignatureRequest(typedData, opts).then(jwt => this.send(jwt, id, sendOpts))
|
353 | }
|
354 |
|
355 | /**
|
356 | * Creates and sends a request to a user to sign an arbitrary data string
|
357 | *
|
358 | * @param {String} data a string representing a piece of arbitrary data
|
359 | * @param {String} [id='personalSignReq']
|
360 | * @param {Object} [sendOpts]
|
361 | */
|
362 | requestPersonalSign(data, id='personalSignReq', sendOpts) {
|
363 | const opts = {
|
364 | callback: this.genCallback(id),
|
365 | net: this.network.id
|
366 | }
|
367 | if (this.address) opts.from = this.address
|
368 |
|
369 | this.credentials.createPersonalSignRequest(data, opts).then(jwt => this.send(jwt, id, sendOpts))
|
370 | }
|
371 |
|
372 | /**
|
373 | * Creates a [Selective Disclosure Request JWT](https://github.com/uport-project/specs/blob/develop/messages/sharereq.md) and sends request message to uPort client.
|
374 | *
|
375 | * @example
|
376 | * const req = { requested: ['name', 'country'],
|
377 | * callbackUrl: 'https://myserver.com',
|
378 | * notifications: true }
|
379 | * const reqID = 'disclosureReq'
|
380 | * connect.requestDisclosure(req, reqID)
|
381 | * connect.onResponse(reqID).then(jwt => {
|
382 | * ...
|
383 | * })
|
384 | *
|
385 | * @param {Object} [reqObj={}] request params object
|
386 | * @param {Array} reqObj.requested an array of attributes for which you are requesting credentials to be shared for
|
387 | * @param {Array} reqObj.verified an array of attributes for which you are requesting verified credentials to be shared for
|
388 | * @param {Boolean} reqObj.notifications boolean if you want to request the ability to send push notifications
|
389 | * @param {String} reqObj.callbackUrl the url which you want to receive the response of this request
|
390 | * @param {String} reqObj.networkId Override default network id of Ethereum chain of identity eg. 0x4 for rinkeby
|
391 | * @param {String} reqObj.rpcUrl Override default JSON RPC url for networkId. This is generally only required for use with private chains.
|
392 | * @param {String} reqObj.accountType Ethereum account type: "general", "keypair", or "none"
|
393 | * @param {Number} reqObj.expiresIn Seconds until expiry
|
394 | * @param {String} [id='disclosureReq'] string to identify request, later used to get response
|
395 | * @param {Object} [sendOpts] reference send function options
|
396 | */
|
397 | async requestDisclosure (reqObj = {}, id = 'disclosureReq', sendOpts) {
|
398 | if (!reqObj.vc) await this.signAndUploadProfile()
|
399 | // Augment request object with verified claims, accountType, and a callback url
|
400 | reqObj = Object.assign({
|
401 | vc: this.vc,
|
402 | accountType: this.accountType || 'none',
|
403 | callbackUrl: this.genCallback(id),
|
404 | }, reqObj)
|
405 |
|
406 | if (reqObj.accountType != 'none') {
|
407 | reqObj.networkId = this.network.id
|
408 | reqObj.rpcUrl = this.network.rpcUrl
|
409 | }
|
410 |
|
411 | // Create and send request
|
412 | this.credentials.createDisclosureRequest(reqObj, reqObj.expiresIn)
|
413 | .then(jwt => this.send(jwt, id, sendOpts))
|
414 | }
|
415 |
|
416 | /**
|
417 | * Create and send a verification (credential) about connnected user. Verification is signed by
|
418 | * temporary session keys created by Connect. If you want to create a verification with a different
|
419 | * keypair/did use uPort credentials and send it with the Connect send function.
|
420 | *
|
421 | * @example
|
422 | * connect.sendVerification({
|
423 | * exp: <future timestamp>,
|
424 | * claim: { name: 'John Smith' }
|
425 | * }, 'REQUEST_ID')
|
426 | * connect.onResponse('REQUEST_ID').then(credential => {
|
427 | * ...
|
428 | * })
|
429 | *
|
430 | * @param {Object} [verification] a unsigned verification object, by default the sub is the connected user
|
431 | * @param {Object} verification.claim a claim about the subject, single key value or key mapping to object with multiple values (ie { address: {street: ..., zip: ..., country: ...}})
|
432 | * @param {String} verification.exp time at which this verification expires and is no longer valid (seconds since epoch)
|
433 | * @param {String} [id='sendVerReq'] string to identify request, later used to get response
|
434 | * @param {Object} [sendOpts] reference send function options
|
435 | */
|
436 | async sendVerification (verification = {}, id = 'sendVerReq', sendOpts) {
|
437 | if (!verification.vc) await this.signAndUploadProfile()
|
438 | // Callback and message form differ for this req, may be reconciled in the future
|
439 | const cb = this.genCallback(id)
|
440 | verification = { sub: this.did, vc: this.vc, ...verification }
|
441 | this.credentials.createVerification(verification).then(jwt => {
|
442 | const uri = message.util.paramsToQueryString(message.util.messageToURI(jwt), {'callback_url': cb})
|
443 | this.send(uri, id, sendOpts)
|
444 | })
|
445 | }
|
446 |
|
447 | /**
|
448 | * Update the internal state of the connect instance and ensure that it is consistent
|
449 | * with the state saved to localStorage. You can pass in an object containing key-value pairs to update,
|
450 | * or a function that returns updated key-value pairs as a function of the current state.
|
451 | *
|
452 | * @param {Function|Object} Update -- An object, or function specifying updates to the current Connect state (as a function of the current state)
|
453 | */
|
454 | setState(update) {
|
455 | switch (typeof update) {
|
456 | case 'object':
|
457 | this._state = { ...this._state, ...update }
|
458 | break
|
459 | case 'function':
|
460 | this._state = update(this._state)
|
461 | break
|
462 | case 'undefined':
|
463 | break
|
464 | default:
|
465 | throw new Error(`Cannot update state with ${update}`)
|
466 | }
|
467 | // Normalize address to mnid
|
468 | const { mnid } = this._state
|
469 | if (isMNID(mnid)) {
|
470 | this._state.address = decode(mnid).address
|
471 | } else if (mnid) {
|
472 | // Don't allow setting an invalid mnid
|
473 | throw new Error(`Invalid MNID: ${this._state.mnid}`)
|
474 | }
|
475 |
|
476 | if (this.publicEncKey && this.pushToken) {
|
477 | this.pushTransport = pushTransport(this.pushToken, this.publicEncKey)
|
478 | }
|
479 |
|
480 | // Write to localStorage
|
481 | if (this.useStore) this.store.set(this._state)
|
482 | }
|
483 |
|
484 | /**
|
485 | * Load state from local storage and set this instance's state accordingly.
|
486 | */
|
487 | loadState() {
|
488 | // replace state
|
489 | if (this.useStore) this.setState(state => this.store.get())
|
490 | }
|
491 |
|
492 | /**
|
493 | * Clear any user-specific state from the browser, (both the Connect instance and localStorage)
|
494 | * effectively logging them out. The keypair (app-instance identity) is preserved
|
495 | */
|
496 | logout() {
|
497 | // Clear explicit state
|
498 | this.setState(state => ({keypair: state.keypair}))
|
499 | // Clear all instance variables with references to current state
|
500 | this.pushTransport = null
|
501 | }
|
502 |
|
503 | /**
|
504 | * Clear the entire state of the connect instance, including the keypair, from memory
|
505 | * and localStorage. Rebuild this.credentials with a new app-instance identity
|
506 | */
|
507 | reset() {
|
508 | this.logout()
|
509 | // Rebuild credentials
|
510 | this.keypair = Credentials.createIdentity()
|
511 | this.credentials = new Credentials({...this.keypair, ...this.resolverConfigs})
|
512 | }
|
513 |
|
514 | /**
|
515 | * Accessor methods for Connect state. The state consists of the key-value pairs below
|
516 | * (did, doc, mnid, address, keypair, pushToken, and publicEncKey)
|
517 | * @private
|
518 | */
|
519 | get state () { return this._state }
|
520 | get did () { return this._state.did }
|
521 | get doc () { return {...this._state.doc} }
|
522 | get mnid () { return this._state.mnid }
|
523 | get address () { return this._state.address }
|
524 | get keypair () { return {...this._state.keypair} }
|
525 | get verified () { return this._state.verified }
|
526 | get pushToken () { return this._state.pushToken }
|
527 | get publicEncKey () { return this._state.publicEncKey }
|
528 |
|
529 | /**
|
530 | * Setter methods with appropriate validation
|
531 | * @private
|
532 | */
|
533 |
|
534 | set state (state) { throw new Error('Use setState to set state object') }
|
535 | set did (did) { this.setState({did}) }
|
536 | set doc (doc) { this.setState({doc}) }
|
537 | set mnid (mnid) { this.setState({mnid}) }
|
538 | set keypair (keypair) { this.setState({keypair}) }
|
539 | set verified (verified) { this.setState({verified}) }
|
540 | set pushToken (pushToken) { this.setState({pushToken}) }
|
541 | set publicEncKey (publicEncKey) { this.setState({publicEncKey}) }
|
542 |
|
543 | // Address field alone is deprectated. Allow setting an mnid, but not an unqualified address
|
544 | set address (address) {
|
545 | if (isMNID(address)) {
|
546 | this.setState({mnid: address})
|
547 | } else {
|
548 | if (address === this.address) return
|
549 | throw new Error('Setting an Ethereum address without a network id is not supported. Use an MNID instead.')
|
550 | }
|
551 | }
|
552 |
|
553 | /**
|
554 | * @private
|
555 | */
|
556 | genCallback (reqId) {
|
557 | return this.isOnMobile ? windowCallback(reqId) : transport.messageServer.genCallback()
|
558 | }
|
559 |
|
560 | /**
|
561 | * @private
|
562 | * Sign a profile object with this.credentials, and upload it to ipfs, prepending
|
563 | * the instance array of verified claims (this.vc) with the ipfs uri. If a profile
|
564 | * object is not provided, create one on the fly
|
565 | * @param {Object} [profile] the profile object to be signed and uploaded
|
566 | * @returns {Promise<String, Error>} a promise resolving to the ipfs hash, or rejecting with an error
|
567 | */
|
568 | signAndUploadProfile (profile) {
|
569 | if (!profile && this.vc.length > 0) return
|
570 | profile = profile || {
|
571 | name: this.appName,
|
572 | description: this.description,
|
573 | url: (typeof window !== 'undefined') ? `${window.location.protocol}//${window.location.host}` : undefined,
|
574 | profileImage: this.profileImage,
|
575 | bannerImage: this.bannerImage,
|
576 | brandColor: this.brandColor,
|
577 | }
|
578 |
|
579 | // Upload to ipfs
|
580 | return this.credentials.createVerification({sub: this.keypair.did, claim: profile})
|
581 | .then(jwt => ipfsAdd(jwt))
|
582 | .then(hash => {
|
583 | console.log('uploaded, ', this.vc)
|
584 | this.vc.unshift(`/ipfs/${hash}`)
|
585 | return hash
|
586 | })
|
587 | }
|
588 | }
|
589 |
|
590 | const LOCALSTOREKEY = 'connectState'
|
591 |
|
592 | class LocalStorageStore {
|
593 | constructor (key = LOCALSTOREKEY) {
|
594 | this.key = key
|
595 | }
|
596 |
|
597 | get() {
|
598 | return JSON.parse(store.get(this.key) || '{}')
|
599 | }
|
600 |
|
601 | set(stateObj) {
|
602 | store.set(this.key, JSON.stringify(stateObj))
|
603 | }
|
604 | }
|
605 |
|
606 | /**
|
607 | * A transport created for uPort connect. Bundles transport functionality from uport-transports. This implements the
|
608 | * default QR modal flow on desktop clients. If given a request which uses the messaging server Chasqui to relay
|
609 | * responses, it will by default poll Chasqui and return response. If given a request which specifies another
|
610 | * callback to receive the response, for example your own server, it will show the request in the default QR
|
611 | * modal and then instantly return. You can then handle how to fetch the response specific to your implementation.
|
612 | *
|
613 | * @param {String} appName App name displayed in QR pop over modal
|
614 | * @return {Function} Configured connectTransport function
|
615 | * @param {String} request uPort client request message
|
616 | * @param {Object} [config={}] Optional config object
|
617 | * @param {String} config.data Additional data to be returned later with response
|
618 | * @return {Promise<Object, Error>} Function to close the QR modal
|
619 | * @private
|
620 | */
|
621 | const connectTransport = (appName) => (request, {data, cancel}) => {
|
622 | if (transport.messageServer.isMessageServerCallback(request)) {
|
623 | return transport.qr.chasquiSend({appName})(request).then(res => ({payload: res, data}))
|
624 | } else {
|
625 | transport.qr.send(appName)(request, {cancel})
|
626 | // TODO return close QR func?
|
627 | return Promise.resolve({data})
|
628 | }
|
629 | }
|
630 |
|
631 | /**
|
632 | * Wrap push transport from uport-transports, providing stored pushToken and publicEncKey from the
|
633 | * provided Connect instance
|
634 | * @param {Connect} connect The Connect instance holding the pushToken and publicEncKey
|
635 | * @returns {Function} Configured pushTransport function
|
636 | * @private
|
637 | */
|
638 | const pushTransport = (pushToken, publicEncKey) => {
|
639 | const send = transport.push.sendAndNotify(pushToken, publicEncKey)
|
640 |
|
641 | return (request, { type, redirectUrl, data}) => {
|
642 | if (transport.messageServer.isMessageServerCallback(request)) {
|
643 | return transport.messageServer.URIHandlerSend(send)(request, {type, redirectUrl})
|
644 | .then(res => {
|
645 | transport.ui.close()
|
646 | return {payload: res, data}
|
647 | })
|
648 | } else {
|
649 | // Return immediately for custom message server
|
650 | send(request, {type, redirectUrl})
|
651 | return Promise.resolve({data})
|
652 | }
|
653 | }
|
654 | }
|
655 |
|
656 | /**
|
657 | * Gets current window url and formats as request callback
|
658 | *
|
659 | * @return {String} Returns window url formatted as callback
|
660 | * @private
|
661 | */
|
662 | const windowCallback = (id) => {
|
663 | const md = new MobileDetect(navigator.userAgent)
|
664 | const chromeAndIOS = (md.userAgent() === 'Chrome' && md.os() === 'iOS')
|
665 | const callback = chromeAndIOS ? `googlechrome:${window.location.href.substring(window.location.protocol.length)}` : window.location.href
|
666 | return message.util.paramsToUrlFragment(callback, {id})
|
667 | }
|
668 |
|
669 | export default Connect
|