@arcblock/did-auth
Version:
Helper function to setup DID authentication support on a node.js web server
764 lines (666 loc) • 23.3 kB
JavaScript
/* 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;