UNPKG

@arcblock/did-auth

Version:

Helper function to setup DID authentication support on a node.js web server

764 lines (666 loc) 23.3 kB
/* eslint-disable no-underscore-dangle */ /* eslint-disable indent */ /* eslint-disable object-curly-newline */ const qs = require('querystring'); const pick = require('lodash/pick'); const random = require('lodash/random'); const shuffle = require('lodash/shuffle'); const isEqual = require('lodash/isEqual'); const Client = require('@ocap/client'); const Jwt = require('@arcblock/jwt'); const RSA = require('@ocap/mcrypto/lib/crypter/rsa').default; const { toDid, toBase58, fromBase58 } = require('@ocap/util'); const { fromAddress } = require('@ocap/wallet'); const { toAddress } = require('@arcblock/did'); const BaseAuthenticator = require('./base'); // eslint-disable-next-line const debug = require('debug')(`${require('../../package.json').name}:authenticator:wallet`); const { DEFAULT_CHAIN_INFO } = BaseAuthenticator; const DEFAULT_TIMEOUT = 8000; const MFA_CODE_COUNT = 3; const schema = require('../schema'); const formatDisplay = (display) => { // empty if (!display) { return ''; } // object like if (display && display.type && display.content) { return JSON.stringify(pick(display, ['type', 'content'])); } // string like try { const parsed = JSON.parse(display); if (parsed && parsed.type && parsed.content) { return display; } return ''; } catch (err) { return ''; } }; class WalletAuthenticator extends BaseAuthenticator { /** * @typedef ApplicationInfo * @prop {string} name - application name * @prop {string} description - application description * @prop {string} icon - application icon/logo url * @prop {string} link - application home page, with which user can return application from wallet * @prop {string} path - deep link url * @prop {string} publisher - application did with `did:abt:` prefix */ /** * @typedef ChainInfo * @prop {string} id - application chain id * @prop {string} type - application chain type * @prop {string} host - graphql endpoint of the application chain */ /** * Creates an instance of DID Authenticator. * * @class * @param {object} config * @param {WalletObject|Function} config.wallet - wallet instance {@see @ocap/wallet} or a function that returns wallet instance * @param {WalletObject|Function} [config.delegator] - the party that authorizes `wallet` to perform actions on behalf of `wallet` * @param {string|Function} [config.delegation] - the jwt token that proves delegation relationship * @param {ApplicationInfo|Function} config.appInfo - application basic info or a function that returns application info * @param {ChainInfo|Function} config.chainInfo - application chain info or a function that returns chain info * @param {Number} [config.timeout=8000] - timeout in milliseconds when generating claim * @param {object} [config.baseUrl] - url to assemble wallet request uri, can be inferred from request object * @param {string} [config.tokenKey='_t_'] - query param key for `token` * @example * const { fromRandom } = require('@ocap/wallet'); * * const wallet = fromRandom().toJSON(); * const chainHost = 'https://beta.abtnetwork.io/api'; * const chainId = 'beta'; * const auth = new Authenticator({ * wallet, * baseUrl: 'http://beta.abtnetwork.io/webapp', * appInfo: { * name: 'DID Wallet Demo', * description: 'Demo application to show the potential of DID Wallet', * icon: 'https://arcblock.oss-cn-beijing.aliyuncs.com/images/wallet-round.png', * }, * memberAppInfo: null, * chainInfo: { * host: chainHost, * id: chainId, * }, * timeout: 8000, * }); */ constructor({ wallet, appInfo, memberAppInfo, delegator, delegation, timeout = DEFAULT_TIMEOUT, chainInfo = DEFAULT_CHAIN_INFO, baseUrl = '', tokenKey = '_t_', }) { super(); this.wallet = this._validateWallet(wallet); this.appInfo = this._validateAppInfo(appInfo); this.memberAppInfo = this._validateAppInfo(memberAppInfo, true); this.chainInfo = chainInfo; this.delegator = delegator; this.delegation = delegation; this.baseUrl = baseUrl; this.tokenKey = tokenKey; this.timeout = timeout; if (!this.appInfo.link) { this.appInfo.link = this.baseUrl; } } /** * Generate a deep link url that can be displayed as QRCode for DID Wallet to consume * * @method * @param {object} params * @param {string} params.baseUrl - baseUrl inferred from request object * @param {string} params.pathname - wallet callback pathname * @param {string} params.token - action token * @param {object} params.query - params that should be persisted in wallet callback url * @returns {string} */ uri({ baseUrl, pathname = '', token = '', query = {} } = {}) { const params = { ...query, [this.tokenKey]: token }; const payload = { action: 'requestAuth', url: encodeURIComponent(`${this.baseUrl || baseUrl}${pathname}?${qs.stringify(params)}`), }; const uri = `https://abtwallet.io/i/?${qs.stringify(payload)}`; debug('uri', { token, pathname, uri, params, payload }); return uri; } /** * Compute public url to return to wallet * * @method * @param {string} pathname * @param {object} params * @returns {string} */ getPublicUrl(pathname, params = {}, baseUrl = '') { return `${this.baseUrl || baseUrl}${pathname}?${qs.stringify(params)}`; } /** * Sign a plain response, usually on auth success or error * * @method * @param {object} params * @param {object} params.response - response * @param {string} params.errorMessage - error message, default to empty * @param {string} params.successMessage - success message, default to empty * @param {string} params.nextWorkflow - https://github.com/ArcBlock/ABT-DID-Protocol#concatenate-multiple-workflow * @param {string} params.nextUrl - tell wallet do open this url in webview * @param {object} params.cookies - key-value pairs to be set as cookie before open nextUrl * @param {object} params.storages - key-value pairs to be set as localStorage before open nextUrl * @param {string} baseUrl * @param {object} request * @returns {Promise<object>} { appPk, agentPk, authInfo } */ async signResponse( { response = {}, errorMessage = '', successMessage = '', nextWorkflow = '', nextUrl = '', cookies = {}, storages = {}, }, baseUrl, request, extraParams = {} ) { const context = request.context || {}; const infoParams = { baseUrl, request, ...context, extraParams }; const [wallet, delegator, delegation] = await Promise.all([ this.getWalletInfo(infoParams), this.getDelegator(infoParams), this.getDelegation(infoParams), ]); const [appInfo, memberAppInfo] = await Promise.all([ this.getAppInfo({ ...infoParams, wallet, delegator }, 'appInfo'), this.getAppInfo({ ...infoParams, wallet, delegator }, 'memberAppInfo'), ]); const didwallet = request.context.wallet; const payload = { appInfo, memberAppInfo, status: errorMessage ? 'error' : 'ok', errorMessage: errorMessage || '', successMessage: successMessage || '', nextWorkflow: nextWorkflow || '', nextUrl: nextUrl || '', cookies: cookies || {}, storages: storages || '', response, }; if (delegator) { payload.iss = toDid(delegator.address); payload.agentDid = toDid(wallet.address); payload.verifiableClaims = [{ type: 'certificate', content: delegation }]; } const result = { appPk: toBase58(wallet.pk), authInfo: Jwt.sign(wallet.address, wallet.sk, payload, true, didwallet ? didwallet.jwt : undefined), }; if (delegator) { result.appPk = toBase58(delegator.pk); result.agentPk = toBase58(wallet.pk); } return result; } /** * Sign a auth response that returned to wallet: tell the wallet the appInfo/chainInfo * * @method * @param {object} params * @param {object} params.claims - info required by application to complete the auth * @param {string} params.pathname - pathname to assemble callback url * @param {string} params.baseUrl - baseUrl * @param {object} params.challenge - random challenge to be included in the body * @param {object} params.extraParams - extra query params and locale * @param {object} params.request * @param {object} params.context * @param {string} params.context.token - action token * @param {number} params.context.currentStep - current step * @param {string} [params.context.sharedKey] - shared key between app and wallet * @param {string} [params.context.encryptionKey] - encryption key from wallet * @param {Function} [params.context.mfaCode] - function used to generate mfa code * @param {string} params.context.userDid - decoded from req.query, base58 * @param {string} params.context.userPk - decoded from req.query, base58 * @param {string} params.context.didwallet - DID Wallet os and version * @returns {Promise<object>} { appPk, agentPk, sharedKey, authInfo } */ async sign({ context, request, claims, pathname = '', baseUrl = '', challenge = '', extraParams = {} }) { // debug('sign.context', context); // debug('sign.params', extraParams); const claimsInfo = await this.tryWithTimeout(() => this.genRequestedClaims({ claims, context: { baseUrl, request, ...context }, extraParams, }) ); if (claimsInfo.filter((x) => x.mfaCode && x.mfaCode.length > 0).length > 1) { throw new Error('Multiple MFA is not supported when sending more than 1 claim'); } // FIXME: this maybe buggy if user provided multiple claims const tmp = claimsInfo.find((x) => isEqual(this._isValidChainInfo(x.chainInfo), DEFAULT_CHAIN_INFO) === false); const infoParams = { baseUrl, request, ...context, extraParams }; const [wallet, delegator, delegation, chainInfo] = await Promise.all([ this.getWalletInfo(infoParams), this.getDelegator(infoParams), this.getDelegation(infoParams), this.getChainInfo(infoParams, tmp?.chainInfo), ]); const [appInfo, memberAppInfo] = await Promise.all([ this.getAppInfo({ ...infoParams, wallet, delegator }, 'appInfo'), this.getAppInfo({ ...infoParams, wallet, delegator }, 'memberAppInfo'), ]); const payload = { action: 'responseAuth', challenge, appInfo, memberAppInfo, chainInfo, requestedClaims: claimsInfo.map((x) => { delete x.chainInfo; return x; }), url: `${this.baseUrl || baseUrl}${pathname}?${qs.stringify({ [this.tokenKey]: context.token })}`, }; if (delegator) { payload.iss = toDid(delegator.address); payload.agentDid = toDid(wallet.address); payload.verifiableClaims = [{ type: 'certificate', content: delegation }]; } // debug('sign.payload', payload); const version = context.didwallet ? context.didwallet.jwt : undefined; const result = { appPk: toBase58(wallet.pk), authInfo: Jwt.sign(wallet.address, wallet.sk, payload, true, version), sensitive: claimsInfo.every((x) => ['keyPair', 'encryptionKey'].includes(x.type)), }; // encrypt context.encKey with user pk here if (result.sensitive && context.sharedKey && context.encryptionKey) { try { const pk = fromBase58(context.encryptionKey).toString('utf8'); result.sharedKey = RSA.encrypt(context.sharedKey, pk, 'base58'); } catch (err) { console.error('Failed to encrypt shared key', err); } } if (delegator) { result.appPk = toBase58(delegator.pk); result.agentPk = toBase58(wallet.pk); } return result; } /** * Determine chainInfo on the fly * * @param {object} params - contains the context of this request * @param {object|undefined} [info=undefined] - chain info object or function * @returns {Promise<ChainInfo>} * @memberof WalletAuthenticator */ async getChainInfo(params, info) { if (info && this._isValidChainInfo(info)) { return info; } if (typeof this.chainInfo === 'function') { const result = await this.tryWithTimeout(() => this.chainInfo(params)); if (this._isValidChainInfo(result)) { return result; } } if (this.chainInfo && this._isValidChainInfo(this.chainInfo)) { return this.chainInfo; } return DEFAULT_CHAIN_INFO; } /** * Determine appInfo/memberAppInfo on the fly * * @param {object} params - contains the context of this request * @param {string} key - appInfo | memberAppInfo * @returns {Promise<ApplicationInfo>} * @memberof WalletAuthenticator */ async getAppInfo(params, key = 'appInfo') { if (typeof this[key] === 'function') { const info = await this.tryWithTimeout(() => this[key](params)); if (info) { if (!info.link) { info.link = params.baseUrl; } if (!info.publisher) { info.publisher = toDid(params.delegator ? params.delegator.address : params.wallet.address); } } return this._validateAppInfo(info, key === 'memberAppInfo'); } if (this[key] && !this[key].publisher) { this[key].publisher = toDid(params.delegator ? params.delegator.address : params.wallet.address); } return this[key]; } async getWalletInfo(params) { if (typeof this.wallet === 'function') { const result = await this.tryWithTimeout(() => this.wallet(params)); return this._validateWallet(result, true); } return this.wallet; } async getDelegator(params) { if (typeof this.delegator === 'function') { const result = await this.tryWithTimeout(() => this.delegator(params)); return result ? this._validateWallet(result, false) : null; } return this.delegator; } async getDelegation(params) { if (typeof this.delegation === 'function') { const result = await this.tryWithTimeout(() => this.delegation(params)); return result; } return this.delegation; } /** * Verify a DID auth response sent from DID Wallet * * @method * @param {object} data * @param {string} [locale=en] * @param {boolean} [enforceTimestamp=true] * @returns Promise<boolean> */ async verify(data, locale = 'en', enforceTimestamp = true) { const { iss, iat, challenge = '', action = 'responseAuth', requestedClaims, } = await this._verify(data, 'userPk', 'userInfo', locale, enforceTimestamp); debug('verify.context', { userPk: data.userPk, userDid: toAddress(iss), action, challenge }); debug('verify.claims', requestedClaims); return { token: data.token, userDid: toAddress(iss), userPk: data.userPk, claims: requestedClaims, action, challenge, timestamp: iat, }; } // --------------------------------------- // Request claim related methods // --------------------------------------- genRequestedClaims({ claims, context, extraParams }) { return Promise.all( Object.keys(claims).map(async (x) => { let name = x; let claim = claims[x]; if (Array.isArray(claims[x])) { [name, claim] = claims[x]; } if (!schema.claims[name]) { throw new Error(`Unsupported claim type ${name}`); } const fn = typeof this[name] === 'function' ? name : 'getClaimInfo'; const result = await this[fn]({ claim, context, extraParams }); if (result.mfa && typeof context.mfaCode === 'function') { result.mfaCode = [await context.mfaCode()]; while (result.mfaCode.length < MFA_CODE_COUNT) { const noise = random(10, 99); if (result.mfaCode.includes(noise) === false) { result.mfaCode.push(noise); } } result.mfaCode = shuffle(result.mfaCode); } const { value, error } = schema.claims[name].validate(result); if (error) { throw new Error(`Invalid ${name} claim: ${error.message}`); } return value; }) ); } async getClaimInfo({ claim, context, extraParams }) { const { userDid, userPk, didwallet } = context; const result = typeof claim === 'function' ? await claim({ userDid: userDid ? toAddress(userDid) : '', userPk: userPk || '', didwallet, extraParams, context, }) : claim; const infoParams = { ...context, ...extraParams }; const chainInfo = await this.getChainInfo(infoParams, result.chainInfo); result.chainInfo = chainInfo; return result; } // Request wallet to sign something: transaction/text/html/image async signature({ claim, context, extraParams }) { const { data, type = 'mime:text/plain', digest = '', method = 'sha3', // set this to `none` to instruct wallet not to hash before signing wallet, sender, display, description: desc, chainInfo, meta = {}, mfa = false, nonce = '', requirement = { tokens: [], assets: {} }, } = await this.getClaimInfo({ claim, context, extraParams, }); debug('claim.signature', { data, digest, type, sender, context, nonce, requirement }); if (!data && !digest) { throw new Error('Signature claim requires either data or digest to be provided'); } const description = desc || 'Sign this transaction to continue.'; // We have to encode the transaction if (type.endsWith('Tx')) { if (!chainInfo.host) { throw new Error('Invalid chainInfo when trying to encoding transaction'); } const client = new Client(chainInfo.host); if (typeof client[`encode${type}`] !== 'function') { throw new Error(`Unsupported transaction type ${type}`); } if (!data.pk) { data.pk = context.userPk; } try { const { buffer: txBuffer } = await client[`encode${type}`]({ tx: data, wallet: wallet || fromAddress(sender || context.userDid), }); return { type: 'signature', description, typeUrl: 'fg:t:transaction', origin: toBase58(txBuffer), method, display: formatDisplay(display), digest: '', chainInfo, meta, mfa, nonce, requirement, }; } catch (err) { throw new Error(`Failed to encode transaction: ${err.message}`); } } // We have en encoded transaction if (type === 'fg:t:transaction') { return { type: 'signature', description, typeUrl: 'fg:t:transaction', origin: toBase58(data), display: formatDisplay(display), method, digest: '', chainInfo, meta, mfa, nonce, requirement, }; } // If we are ask user to sign anything just pass the data // Wallet should not hash the data if `method` is empty // If we are asking user to sign a very large piece of data // Just hash the data and show him the digest return { type: 'signature', description: desc || 'Sign this message to continue.', origin: data ? toBase58(data) : '', typeUrl: type, display: formatDisplay(display), method, digest, chainInfo, meta, mfa, nonce, requirement, }; } // Request wallet to complete and sign a partial tx to broadcasting // Usually used in payment scenarios // The wallet can leverage multiple input capabilities of the chain async prepareTx({ claim, context, extraParams }) { const { partialTx, requirement = { tokens: [], assets: {} }, type, display, wallet, sender, description: desc, chainInfo, meta = {}, mfa = false, nonce = '', } = await this.getClaimInfo({ claim, context, extraParams, }); debug('claim.prepareTx', { partialTx, requirement, type, sender, context }); if (!partialTx || !requirement) { throw new Error('prepareTx claim requires both partialTx and requirement to be provided'); } const description = desc || 'Prepare and sign this transaction to continue.'; // We have to encode the transaction if (type && type.endsWith('Tx')) { if (!chainInfo.host) { throw new Error('Invalid chainInfo when trying to encoding partial transaction'); } const client = new Client(chainInfo.host); if (typeof client[`encode${type}`] !== 'function') { throw new Error(`Unsupported transaction type ${type} when encoding partial transaction`); } if (!partialTx.pk) { partialTx.pk = context.userPk; } try { const { buffer: txBuffer } = await client[`encode${type}`]({ tx: partialTx, wallet: wallet || fromAddress(sender || context.userDid), }); return { type: 'prepareTx', description, partialTx: toBase58(txBuffer), display: formatDisplay(display), requirement, chainInfo, meta, mfa, nonce, }; } catch (err) { throw new Error(`Failed to encode partial transaction: ${err.message}`); } } // We have en encoded transaction return { type: 'prepareTx', description, partialTx: toBase58(partialTx), requirement, display: formatDisplay(display), chainInfo, meta, mfa, nonce, }; } _validateAppInfo(info, allowEmpty = false) { if (typeof info === 'function') { return info; } if (!info) { if (allowEmpty === false) { throw new Error('Wallet authenticator can not work with invalid appInfo: empty'); } return null; } const { value, error } = schema.appInfo.validate(info); if (error) { throw new Error(`Wallet authenticator can not work with invalid appInfo: ${error.message}`); } return value; } _isValidChainInfo(x) { const { error } = schema.chainInfo.validate(x); return !error; } tryWithTimeout(asyncFn) { if (typeof asyncFn !== 'function') { throw new Error('asyncFn must be a valid function'); } const timeout = Number(this.timeout) || DEFAULT_TIMEOUT; // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Async operation did not complete within ${timeout} ms`)); }, timeout); try { const result = await asyncFn(); resolve(result); } catch (err) { reject(err); } finally { clearTimeout(timer); } }); } } module.exports = WalletAuthenticator; module.exports.formatDisplay = formatDisplay;