UNPKG

@arcblock/did-auth

Version:

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

944 lines (836 loc) 33.1 kB
/* eslint-disable no-underscore-dangle */ /* eslint-disable prefer-destructuring */ /* eslint-disable object-curly-newline */ /* eslint-disable consistent-return */ const url = require('url'); const get = require('lodash/get'); const set = require('lodash/set'); const pick = require('lodash/pick'); const omit = require('lodash/omit'); const random = require('lodash/random'); const cloneDeep = require('lodash/cloneDeep'); const isEqual = require('lodash/isEqual'); const isPlainObject = require('lodash/isPlainObject'); const semver = require('semver'); const Mcrypto = require('@ocap/mcrypto'); const SealedBox = require('tweetnacl-sealedbox-js'); const stringify = require('json-stable-stringify'); const { isValid: isValidDid } = require('@arcblock/did'); const { stripHexPrefix, toBase64, fromBase64 } = require('@ocap/util'); const { encrypt, decrypt, SESSION_STATUS, PROTECTED_KEYS } = require('../protocol'); // Scheme: https://tools.ietf.org/html/rfc3986#section-3.1 // Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3 const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/; // Windows paths like `c:\` const WINDOWS_PATH_REGEX = /^[a-zA-Z]:\\/; const isUrl = (input) => { if (typeof input !== 'string') { return false; } if (WINDOWS_PATH_REGEX.test(input)) { return false; } return ABSOLUTE_URL_REGEX.test(input); }; // eslint-disable-next-line const debug = require('debug')(`${require('../../package.json').name}:handlers:util`); const sha3 = Mcrypto.Hasher.SHA3.hash256; const getLocale = (req) => (req.acceptsLanguages('en-US', 'zh-CN') || 'en-US').split('-').shift(); const getSessionId = () => Date.now().toString(); const noop = () => ({}); const noTouch = (x) => x; const errors = { tokenMissing: { en: 'Session Id is required to check status', zh: '缺少会话 ID 参数', }, didMismatch: { en: 'Login user and wallet user mismatch, please relogin and try again', zh: '登录用户和扫码用户不匹配,为保障安全,请重新登录应用', }, mfaMismatch: { en: 'Dynamic verification code mismatch, please try again later', zh: '动态验证码不匹配,请重试', }, challengeMismatch: { en: 'Challenge mismatch', zh: '随机校验码不匹配', }, token404: { en: 'Session not found or expired', zh: '会话不存在或已过期', }, didMissing: { en: 'userDid is required to start auth', zh: 'userDid 参数缺失,请勿尝试连接多个不同的钱包', }, pkMissing: { en: 'userPk is required to start auth', zh: 'userPk 参数缺失,请勿尝试连接多个不同的钱包', }, authClaim: { en: 'authPrincipal claim is not configured correctly', zh: 'authPrincipal 声明配置不正确', }, userDeclined: { en: 'You have declined the authentication request', zh: '授权请求被拒绝', }, userBusy: { en: 'Busy processing another DID Connect request', zh: '正在处理其他请求', }, }; // This logic exist because the handlers maybe attached to a nested router // pathname pattern: /:prefix/:action/auth // But the group of handlers may be attached to a sub router, which has a baseUrl of `/api/login` (can only be extracted from `req.originalUrl`) // We need to ensure the full url is given to DID Wallet // eg: `/agent/login/auth` on the current router will be converted to `/api/login/agent/login/auth` const _preparePathname = (path, req) => { const delimiter = path.replace(/\/retrieve$/, '').replace(/\/auth$/, ''); const fullPath = url.parse(req.originalUrl).pathname; const [prefix] = fullPath.split(delimiter); const cleanPath = [prefix, path].join('/').replace(/\/+/g, '/'); // console.log('preparePathname', { path, delimiter, fullPath, prefix, cleanPath }); return cleanPath; }; const getBaseUrl = (req) => { if (req.headers['x-path-prefix']) { return `/${req.headers['x-path-prefix']}/`.replace(/\/+/g, '/'); } return '/'; }; // This makes the lib smart enough to infer baseURL from request object const prepareBaseUrl = (req, params) => { const pathPrefix = getBaseUrl(req).replace(/\/$/, ''); const [hostname = '', port = 80] = ( req.get('x-forwarded-host') || req.get('x-real-hostname') || req.get('host') || '' ).split(':'); // NOTE: x-real-port exist because sometimes the auth api is behind a port-forwarding proxy const finalPort = get(params, 'x-real-port', null) || req.get('X-Real-Port') || port || ''; return url.format({ protocol: get(params, 'x-real-protocol') || req.get('X-Real-protocol') || req.get('X-Forwarded-Proto') || req.protocol, hostname, port: Number(finalPort) === 80 ? '' : finalPort, pathname: pathPrefix, }); }; // https://github.com/joaquimserafim/base64-url/blob/54d9c9ede66a8724f280cf24fd18c38b9a53915f/index.js#L10 const unescape = (str) => (str + '==='.slice((str.length + 3) % 4)).replace(/-/g, '+').replace(/_/g, '/'); const decodeEncKey = (str) => new Uint8Array(Buffer.from(unescape(str), 'base64')); const getStepChallenge = () => stripHexPrefix(Mcrypto.getRandomBytes(16)).toUpperCase(); const parseWalletUA = (userAgent) => { const ua = (userAgent || '').toString().toLowerCase(); let os = ''; let version = ''; if (ua.indexOf('android') > -1) { os = 'android'; } else if (ua.indexOf('iphone') > -1) { os = 'ios'; } else if (ua.indexOf('ipad') > -1) { os = 'ios'; } else if (ua.indexOf('ipod') > -1) { os = 'ios'; } else if (ua.indexOf('arcwallet') === 0) { os = 'web'; } else if (ua.indexOf('abtwallet') === 0) { os = 'web'; } const match = ua.split(/\s+/).find((x) => x.startsWith('arcwallet/') || x.startsWith('abtwallet/')); if (match) { const tmp = match.split('/'); if (tmp.length > 1 && semver.coerce(tmp[1])) { version = semver.coerce(tmp[1]).version; } } return { os, version, jwt: '1.1.0' }; }; const isDeepLink = (str) => str.startsWith('https://abtwallet.io/i/') || str.startsWith('https://didwallet.io/i/'); // whether we should force did-wallet to connect with userDid from session const isConnectedOnly = (params, sessionUserDid = '') => { if (typeof params.forceConnected === 'string' && isValidDid(params.forceConnected)) { return params.forceConnected; } if (!sessionUserDid) { return false; } if (isValidDid(sessionUserDid) === false) { return false; } if (params.connectedDid !== sessionUserDid) { return false; } // auto if (typeof params.forceConnected === 'undefined') { return true; } // query string from client: `true` | `false` | did if (typeof params.forceConnected === 'string') { try { return !!JSON.parse(params.forceConnected); } catch { return false; } } return !!params.forceConnected; }; // If we treat an did-connect roundtrip as a session, then action token is the session id module.exports = function createHandlers({ action, pathname, claims, onStart, onConnect, onAuth, onDecline, onComplete, onExpire, onError, pathTransformer, tokenStorage, authenticator, authPrincipal, persistentDynamicClaims = false, getSignParams = noop, getPathName = noTouch, options, }) { const { tokenKey, encKey, versionKey, cleanupDelay } = options; const defaultSteps = (Array.isArray(claims) ? claims : [claims]).filter(Boolean); // Smart detection of user-defined authPrincipal claim if (defaultSteps.length > 0) { const keys = Object.keys(defaultSteps[0]); const firstClaim = defaultSteps[0][keys[0]]; if (Array.isArray(firstClaim)) { if (firstClaim[0] === 'authPrincipal') { // eslint-disable-next-line no-param-reassign authPrincipal = false; } } else if (keys[0] === 'authPrincipal') { // eslint-disable-next-line no-param-reassign authPrincipal = false; } } // Prepend default authPrincipal claim if not set if (authPrincipal) { let target = ''; let description = 'Please continue with your account'; let chainInfo; let targetType; if (typeof authPrincipal === 'string') { if (isValidDid(authPrincipal)) { // If auth principal is provided as a did target = authPrincipal; } else { // If auth principal is provided as a string description = authPrincipal; } } if (typeof authPrincipal === 'object') { target = get(authPrincipal, 'target', target); description = get(authPrincipal, 'description', description); targetType = get(authPrincipal, 'targetType', targetType); // If provided a chainInfo if (authPrincipal.chainInfo && authenticator._isValidChainInfo(authPrincipal.chainInfo)) { chainInfo = authPrincipal.chainInfo; } } const supervised = defaultSteps.length === 0; defaultSteps.unshift({ authPrincipal: { skippable: true, description, target, chainInfo, targetType, supervised, }, }); } // Whether we can skip the authPrincipal step safely const canSkipConnect = defaultSteps[0] && defaultSteps[0].authPrincipal && defaultSteps[0].authPrincipal.skippable; const createExtraParams = (locale, params, extra = {}) => { const finalParams = { ...params, ...(extra || {}) }; return { locale, action, ...Object.keys(finalParams) .filter((x) => !['userDid', 'userInfo', 'userSession', 'appSession', 'userPk', 'token'].includes(x)) .reduce((obj, x) => { obj[x] = finalParams[x]; return obj; }, {}), }; }; const createSessionUpdater = (token, params) => // eslint-disable-next-line require-await async (key, value, secure = false) => { const getUpdate = (k, v) => { if (secure && params[encKey]) { const encrypted = SealedBox.seal(Buffer.from(stringify(v)), decodeEncKey(params[encKey])); return { [k]: Buffer.from(encrypted).toString('base64') }; } return { [k]: v }; }; // If key is an object, update multiple keys if (typeof key === 'object') { secure = value; // eslint-disable-line no-param-reassign const keys = Object.keys(key); const updates = Object.assign(...keys.map((k) => getUpdate(k, key[k]))); return tokenStorage.update(token, updates); } return tokenStorage.update(token, omit(getUpdate(key, value), PROTECTED_KEYS)); }; // used for multi-factor authentication const createMfaCodeGenerator = (token) => async () => { const mfaCode = random(10, 99); await tokenStorage.update(token, { mfaCode }); return mfaCode; }; const onProcessError = ({ req, res, stage, err }) => { const { token, store } = req.context || {}; if (token) { tokenStorage.update(token, { status: SESSION_STATUS.ERROR, error: err.message, mfaCode: 0 }); } res.jsonp({ error: err.message }); onError({ token, extraParams: get(store, 'extraParams', {}), stage, err }); }; const preparePathname = (str, req) => { const auto = _preparePathname(str, req); const custom = pathTransformer(auto); // console.log('preparePathname', { str, auto, custom }); return custom; }; // For web app const generateSession = async (req, res) => { try { const params = { 'x-real-port': req.get('x-real-port'), 'x-real-protocol': req.get('x-real-protocol'), deviceDid: req.get('x-device-did'), connectedDid: get(req, 'cookies.connected_did', ''), connectedPk: get(req, 'cookies.connected_pk', ''), ...req.body, ...req.query, ...req.params, }; // force to connected user if we are during a session, and this behavior is customizable params.forceConnected = isConnectedOnly(params, req.get('x-user-did')); const token = sha3(getSessionId({ req, action, pathname })).replace(/^0x/, '').slice(0, 8); await tokenStorage.create(token, SESSION_STATUS.CREATED); // These fields are used to track the source session // - sourceToken is the token of previous session // - destToken is the token of final target session let sourceToken = params.sourceToken || ''; const sourceTokenState = sourceToken ? await tokenStorage.read(sourceToken) : null; if (sourceTokenState) { if ([SESSION_STATUS.SUCCEED].includes(sourceTokenState.status)) { sourceToken = ''; } else { await tokenStorage.update(sourceToken, { destToken: token }); } } const finalPath = preparePathname(getPathName(pathname, req), req); const baseUrl = prepareBaseUrl(req, params); const uri = await authenticator.uri({ token, pathname: finalPath, baseUrl, query: {} }); // Always set currentStep to 0 when generate a new token // Since the did of logged in user may be different of the auth did const challenge = getStepChallenge(); const didwallet = parseWalletUA(req.query['user-agent'] || req.headers['user-agent']); const extraParams = createExtraParams(getLocale(req), params); const hookParams = { req, request: req, challenge, baseUrl, deepLink: uri, extraParams, updateSession: createSessionUpdater(token, extraParams), didwallet, }; const [wallet, delegator] = await Promise.all([ authenticator.getWalletInfo({ baseUrl, request: req, extraParams }), authenticator.getDelegator({ baseUrl, request: req, extraParams }), ]); const [appInfo, memberAppInfo] = await Promise.all([ authenticator.getAppInfo({ baseUrl, request: req, wallet, delegator, extraParams }, 'appInfo'), authenticator.getAppInfo({ baseUrl, request: req, wallet, delegator, extraParams }, 'memberAppInfo'), ]); await tokenStorage.update(token, { currentStep: 0, mfaSupported: !didwallet.os, // If we are mobile (both webview and mobile browsers , os is truthy), mfa should be disabled challenge, sharedKey: getStepChallenge(), // used for wallet to encrypt userInfo extraParams: params, appInfo, memberAppInfo, sourceToken, }); // debug('generate token', { action, pathname, token }); // The data returned by onStart will be set to extra of response data // {String} extra.connectedDid:The server will notify the connectedDid in wallet to automatically connect (no code scanning is required) (notification is unreliable) // {Boolean} extra.saveConnect: The server tells the app web side to remember the connect session did after the connect is complete const extra = await onStart(hookParams); res.jsonp({ token, status: SESSION_STATUS.CREATED, url: uri, appInfo, memberAppInfo, extra: extra || {} }); } catch (err) { onProcessError({ req, res, stage: 'generate-token', err }); } }; // For web app const checkSession = async (req, res) => { try { const { locale, token, store, params } = req.context; if (!token) { res.status(400).json({ error: errors.tokenMissing[locale] }); return; } if (!store) { res.status(400).json({ error: errors.token404[locale] }); return; } if (store.status === SESSION_STATUS.FORBIDDEN) { res.status(403).json({ error: errors.didMismatch[locale] }); return; } if (store.status === SESSION_STATUS.SUCCEED) { setTimeout(() => { tokenStorage.delete(token).catch(console.error); }, cleanupDelay); const extraParams = createExtraParams(locale, params, get(store, 'extraParams', {})); await onComplete({ req, request: req, userDid: store.did, userPk: store.pk, extraParams, updateSession: createSessionUpdater(token, extraParams), }); } res.status(200).json( Object.keys(store) .filter((x) => PROTECTED_KEYS.includes(x) === false) .reduce((acc, key) => { acc[key] = store[key]; return acc; }, {}) ); } catch (err) { onProcessError({ req, res, stage: 'check-token-status', err }); } }; // For web app const expireSession = async (req, res) => { try { const { locale, token, store } = req.context; if (!token) { res.status(400).json({ error: errors.tokenMissing[locale] }); return; } if (!store) { res.status(400).json({ error: errors.token404[locale] }); return; } onExpire({ token, extraParams: get(store, 'extraParams', {}), status: 'expired' }); // We do not delete tokens that are scanned by wallet since it will cause confusing if (store.status !== SESSION_STATUS.SCANNED) { await tokenStorage.delete(token); } res.status(200).json({ token }); } catch (err) { onProcessError({ req, res, stage: 'mark-token-timeout', err }); } }; // Only check userDid and userPk if we have done auth principal const checkUser = async ({ context, userDid, userPk }) => { const { locale, token, store } = context; const isConnected = store.currentStep > 0; // Only check userDid and userPk if we have done auth principal if (isConnected) { if (!userDid) { return errors.didMissing[locale]; } if (!userPk) { return errors.pkMissing[locale]; } // check userDid mismatch if (userDid !== store.did) { await tokenStorage.update(token, { status: SESSION_STATUS.FORBIDDEN }); return errors.didMismatch[locale]; } } return false; }; // eslint-disable-next-line consistent-return const onAuthRequest = async (req, res) => { const { locale, token, store, params, didwallet } = req.context; const extraParams = createExtraParams(locale, params, get(store, 'extraParams', {})); const userDid = params.userDid || store.did || extraParams.connectedDid; const userPk = params.userPk || store.pk || extraParams.connectedPk; const error = await checkUser({ context: req.context, userDid, userPk }); if (error) { return res.jsonp({ error }); } if (params[versionKey] && store.clientVersion !== params[versionKey]) { store.clientVersion = params[versionKey]; store.encryptionKey = params[encKey]; await tokenStorage.update(token, { clientVersion: params[versionKey], encryptionKey: params[encKey] }); } try { const steps = [...cloneDeep(defaultSteps)]; const shouldSkipConnect = canSkipConnect && !!extraParams.connectedDid; if (shouldSkipConnect) { set(steps, '[0].authPrincipal.supervised', false); } if (extraParams.forceConnected) { let target = extraParams.connectedDid; if (typeof extraParams.forceConnected === 'string' && isValidDid(extraParams.forceConnected)) { target = extraParams.forceConnected; } if (isValidDid(target)) { set(steps, '[0].authPrincipal.target', target); } } if (store.status !== SESSION_STATUS.SCANNED) { await tokenStorage.update(token, { status: SESSION_STATUS.SCANNED, connectedWallet: didwallet }); } // Since we can not store dynamic claims anywhere, we should calculate it on the fly if (store.dynamic || shouldSkipConnect) { const newClaims = await onConnect({ req, request: req, userDid, userPk, didwallet, challenge: store.challenge, pathname: preparePathname(getPathName(pathname, req), req), baseUrl: prepareBaseUrl(req, extraParams), extraParams, updateSession: createSessionUpdater(token, extraParams), }); if (newClaims) { if (Array.isArray(newClaims)) { steps.push(...newClaims); } else { steps.push(newClaims); } } } const signParams = await getSignParams(req); const signedClaim = await authenticator.sign( Object.assign(signParams, { context: { token, userDid, userPk, didwallet, ...pick(store, ['currentStep', 'sharedKey', 'encryptionKey']), mfaCode: store.mfaSupported ? createMfaCodeGenerator(token) : undefined, }, claims: steps[store.currentStep], pathname: preparePathname(getPathName(pathname, req), req), baseUrl: prepareBaseUrl(req, extraParams), extraParams, challenge: store.challenge, appInfo: store.appInfo, memberAppInfo: store.memberAppInfo, request: req, }) ); res.jsonp(encrypt(signedClaim, store)); } catch (err) { onProcessError({ req, res, stage: 'send-auth-claim', err }); } }; // eslint-disable-next-line consistent-return const onAuthResponse = async (req, res) => { const { locale, token, store, params, didwallet } = req.context; try { const { userDid, userPk, action: userAction, challenge: userChallenge, claims: claimResponse, timestamp, } = await authenticator.verify(decrypt(params, store), locale); // debug('onAuthResponse.verify', { userDid, token, claims: claimResponse }); if (!store.did || !store.pk) { await tokenStorage.update(token, { did: userDid, pk: userPk }); } const extraParams = createExtraParams(locale, params, get(store, 'extraParams', {})); const cbParams = { step: store.currentStep, req, request: req, userDid, userPk, challenge: store.challenge, didwallet, claims: claimResponse, baseUrl: prepareBaseUrl(req, extraParams), extraParams, updateSession: createSessionUpdater(token, extraParams), timestamp, }; const steps = [...defaultSteps]; const shouldSkipConnect = canSkipConnect && !!extraParams.connectedDid; // Since we can not store dynamic claims anywhere, we should calculate it on the fly if (store.dynamic || shouldSkipConnect) { const newClaims = await onConnect(cbParams); if (newClaims) { if (Array.isArray(newClaims)) { steps.push(...newClaims); } else { steps.push(newClaims); } } } else if (persistentDynamicClaims && Array.isArray(store.dynamicClaims)) { steps.push(...store.dynamicClaims); } // Ensure user approval if (userAction === 'declineAuth') { await tokenStorage.update(token, { status: SESSION_STATUS.ERROR, error: errors.userDeclined[locale], mfaCode: 0, currentStep: steps.length - 1, }); const result = await onDecline(cbParams); return res.jsonp({ ...(result || {}) }); } if (userAction === 'busy') { await tokenStorage.update(token, { status: SESSION_STATUS.BUSY, error: errors.userBusy[locale], mfaCode: 0, currentStep: steps.length - 1, }); return res.jsonp({}); } // Since only 1 MFA is allowed when multiple claims is requested, we just need to check the first one if (store.mfaCode && !claimResponse.some((x) => isEqual(x.mfaCode, [store.mfaCode]))) { return onProcessError({ req, res, stage: 'verify-mfa-code', err: new Error(errors.mfaMismatch[locale]) }); } // Ensure JWT challenge match if (!userChallenge) { return res.jsonp({ error: errors.challengeMismatch[locale] }); } if (userChallenge !== store.challenge) { return res.jsonp({ error: errors.challengeMismatch[locale] }); } // Ensure userDid match between authPrincipal and later process const error = await checkUser({ context: req.context, userDid, userPk }); if (error) { return res.jsonp({ error }); } const isConnected = store.currentStep > 0; if (isConnected === false) { // Some permission check login can be done here // Error thrown from this callback will terminate the process const newClaims = await onConnect(cbParams); if (newClaims) { await tokenStorage.update(token, { dynamic: !persistentDynamicClaims, dynamicClaims: persistentDynamicClaims ? newClaims : undefined, }); if (Array.isArray(newClaims)) { steps.push(...newClaims); } else { steps.push(newClaims); } } } const onLastStep = async (result) => { let nextWorkflow = isUrl(extraParams.nw) ? extraParams.nw : ''; if (nextWorkflow && result && result.nextWorkflowData) { if (isPlainObject(result.nextWorkflowData) === false) { const err = new Error(`expect nextWorkflowData should be a plain object, got: ${result.nextWorkflowData}`); return onProcessError({ req, res, stage: 'validate-next-workflow-data', err }); } const tmp = new URL(nextWorkflow); const merged = Object.assign(extraParams.previousWorkflowData || {}, result.nextWorkflowData); const previousWorkflowData = toBase64(JSON.stringify(merged)); // For max url length please refer to discussion at https://stackoverflow.com/a/417184/686854 if (previousWorkflowData.length > 8192) { const err = new Error('base64 encoded nextWorkflowData should be less than 8192 characters'); return onProcessError({ req, res, stage: 'append-next-workflow', err }); } if (isDeepLink(nextWorkflow)) { const actualUrl = decodeURIComponent(tmp.searchParams.get('url')); const obj = new URL(actualUrl); obj.searchParams.set('previousWorkflowData', previousWorkflowData); tmp.searchParams.set('url', obj.href); } else { tmp.searchParams.set('previousWorkflowData', previousWorkflowData); } nextWorkflow = tmp.href; } const updates = {}; // If we have nextWorkflow, return it to browser let actualNw = nextWorkflow || result?.nextWorkflow || ''; if (actualNw) { if (isDeepLink(actualNw)) { actualNw = new URL(actualNw).searchParams.get('url'); } if (actualNw) { updates.nextWorkflow = decodeURIComponent(actualNw); } } // If we have nextWorkflow, do not mark current session as complete // Instead, save the relationship between the two // Then, mark both session as complete on nextWorkflow complete // In theory, we can use this mechanism to concat infinite sessions if (result && result.nextToken && result.nextWorkflow) { try { await tokenStorage.update(result.nextToken, { prevToken: token }); } catch (err) { console.error('DIDAuth: failed to to update nextToken', err); updates.status = SESSION_STATUS.SUCCEED; } } else { if (store.prevToken) { try { await tokenStorage.update(store.prevToken, { status: SESSION_STATUS.SUCCEED }); } catch (err) { console.error('DIDAuth: failed to to update prevToken', err); } } updates.status = SESSION_STATUS.SUCCEED; } await tokenStorage.update(token, updates); return res.jsonp({ ...Object.assign({ nextWorkflow }, result || {}) }); }; // If we are only requesting the authPrincipal claim // We make such assertion here, because the onConnect callback can modify the steps if (steps.length === 1) { const result = await onAuth(cbParams); return onLastStep(result); } // If we got requestedClaims other than the authPrincipal if (isConnected && store.currentStep < steps.length) { // Call onAuth on each step, since we do not hold all results until complete const result = await onAuth(cbParams); // Only return if we are walked through all steps const isLastStep = store.currentStep === steps.length - 1; if (isLastStep) { return onLastStep(result); } } // Move to next step: nextStep is persisted here to avoid an memory storage error const nextStep = store.currentStep + 1; const nextChallenge = getStepChallenge(); await tokenStorage.update(token, { currentStep: nextStep, challenge: nextChallenge, mfaCode: 0 }); const signParams = await getSignParams(req); try { const nextSignedClaim = await authenticator.sign( Object.assign(signParams, { context: { token, userDid, userPk, didwallet, ...pick(store, ['currentStep', 'sharedKey', 'encryptionKey']), mfaCode: store.mfaSupported ? createMfaCodeGenerator(token) : undefined, }, claims: steps[nextStep], pathname: preparePathname(getPathName(pathname, req), req), baseUrl: prepareBaseUrl(req, extraParams), extraParams, challenge: nextChallenge, appInfo: store.appInfo, memberAppInfo: store.memberAppInfo, request: req, }) ); return res.jsonp(encrypt(nextSignedClaim, store)); } catch (err) { return onProcessError({ req, res, stage: 'next-auth-claim', err }); } } catch (err) { onProcessError({ req, res, stage: 'verify-auth-claim', err }); } }; const ensureContext = async (req, res, next) => { const didwallet = parseWalletUA(req.query['user-agent'] || req.headers['user-agent']); const params = { ...req.body, ...req.query, ...req.params }; const token = params[tokenKey]; const locale = getLocale(req); let store = null; if (token) { store = await tokenStorage.read(token); if (params.previousWorkflowData) { try { store.extraParams.previousWorkflowData = JSON.parse(fromBase64(params.previousWorkflowData)); await tokenStorage.update(token, { extraParams: store.extraParams }); } catch (e) { console.warn('Could not parse previousWorkflowData', params.previousWorkflowData, e); } } if (store?.destToken && typeof params.notrace === 'undefined') { const result = await tokenStorage.read(store.destToken); if (result) { store = result; } } } req.context = { locale, token, didwallet, params, store }; return next(); }; const ensureSignedJson = (req, res, next) => { if (req.ensureSignedJson === undefined) { req.ensureSignedJson = true; const originJsonp = res.jsonp; res.jsonp = async (payload) => { if (payload.appPk && payload.authInfo) { return originJsonp.call(res, payload); } const data = payload.response ? { response: payload.response } : { response: payload }; const fields = ['error', 'errorMessage', 'successMessage', 'nextWorkflow', 'nextUrl', 'cookies', 'storages']; // Attach protocol fields to the root fields.forEach((x) => { if (payload[x]) { data[x] = payload[x]; } }); data.errorMessage = data.error || data.errorMessage || ''; // Remove protocol fields from the response if (typeof data.response === 'object') { data.response = omit(data.response, fields); } const params = { ...req.body, ...req.query, ...req.params }; const token = params[tokenKey]; const store = token ? await tokenStorage.read(token) : null; const extraParams = get(store, 'extraParams', {}); const signedData = await authenticator.signResponse(data, prepareBaseUrl(req, extraParams), req, extraParams); // debug('ensureSignedJson.do', signed); originJsonp.call(res, encrypt(signedData, store)); }; } const { token, store, locale } = req.context; if (!token || !store) { return res.jsonp({ error: errors.token404[locale] }); } next(); }; return { generateSession, expireSession, checkSession, onAuthRequest, onAuthResponse, ensureContext, ensureSignedJson, createExtraParams, }; }; module.exports.isDeepLink = isDeepLink; module.exports.isConnectedOnly = isConnectedOnly; module.exports.parseWalletUA = parseWalletUA; module.exports.preparePathname = _preparePathname; module.exports.prepareBaseUrl = prepareBaseUrl; module.exports.getStepChallenge = getStepChallenge;