UNPKG

@did-connect/handler

Version:

Abstract handler for did-connect relay server

538 lines (537 loc) 25.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.createHandlers = exports.createSocketServer = void 0; /* eslint-disable @typescript-eslint/return-await */ const pick_1 = __importDefault(require("lodash/pick")); const isEmpty_1 = __importDefault(require("lodash/isEmpty")); const isEqual_1 = __importDefault(require("lodash/isEqual")); const p_wait_for_1 = __importDefault(require("p-wait-for")); const jwt_1 = require("@arcblock/jwt"); const axios_1 = __importDefault(require("axios")); // @ts-ignore const object_hash_1 = __importDefault(require("object-hash")); // @ts-ignore const ws_1 = require("@arcblock/ws"); const did_1 = require("@arcblock/did"); const types_1 = require("@did-connect/types"); const util_1 = require("./util"); const errors = { sessionNotFound: { en: 'Session not found or expired', zh: '会话不存在或已过期', }, didMismatch: { en: 'Login user and wallet user mismatch, please reconnect and try again', zh: '登录用户和扫码用户不匹配,为保障安全,请重新登录应用', }, challengeMismatch: { en: 'Challenge mismatch', zh: '随机校验码不匹配', }, userDeclined: { en: 'You have declined the authentication request', zh: '授权请求被拒绝', }, userCanceled: { en: 'User has canceled the request from app', zh: '用户在应用端取消了请求', }, claimMismatch: { en: 'Claims provided by wallet do not match with requested claims', zh: '提交的声明类型和请求的声明类型不匹配', }, invalidUpdaterPk: { en: 'Updater pk is required', zh: '更新者公钥是必须的', }, invalidUpdaterToken: { en: 'Updater signed token is required', zh: '更新者签名是必须的', }, invalidUpdaterSig: { en: 'Updater signature token is invalid', zh: '更新者签名无效', }, invalidUpdaterDid: { en: 'Updater did mismatch', zh: '更新者 DID 不匹配', }, invalidPayloadHash: { en: 'Payload hash mismatch', zh: '请求指纹不匹配', }, invalidContext: { en: 'Context is invalid', zh: '上下文无效', }, invalidSessionId: { en: 'Session id is invalid', zh: '会话 ID 无效', }, invalidSessionProp: { en: 'Invalid session props: {error}', zh: '无效的会话属性: {error}', }, sessionFinalized: { en: 'Session finalized as {status}', zh: '会话已经完成为 {status}', }, sessionStatusInvalid: { en: 'Can only update session status to error or canceled', zh: '只能更新会话状态为 error 或 canceled', }, invalidSessionUpdate: { en: 'Invalid session updates: {error}', zh: '无效的会话更新: {error}', }, invalidConnectUrl: { en: 'Failed to fetch request list from: {url}: {error}', zh: '请求 connectUrl 失败: {url}: {error}', }, invalidApproveUrl: { en: 'Failed to fetch approve result from: {url}: {error}', zh: '请求 approveUrl 失败: {url}: {error}', }, }; function createSocketServer(logger, pathname) { return new ws_1.WsServer({ logger, pathname }); } exports.createSocketServer = createSocketServer; function createHandlers({ storage, authenticator, logger = console, socketPathname = '/api/connect/relay/websocket', }) { const wsServer = createSocketServer(logger, socketPathname); const isValidContext = (x) => { const { error } = types_1.Context.validate(x); if (error) { logger.error(error); } return !error; }; const signJson = authenticator.signJson.bind(authenticator); const signClaims = authenticator.signClaims.bind(authenticator); // @ts-ignore const verifyUpdater = async (params, updaterPk) => { const { locale, body, signerPk, signerToken } = params; if (!signerPk) { return { error: errors.invalidUpdaterPk[locale], code: 'UPDATER_PK_EMPTY' }; } if (!signerToken) { return { error: errors.invalidUpdaterToken[locale], code: 'SIGNATURE_EMPTY' }; } if ((await (0, jwt_1.verify)(signerToken, signerPk)) === false) { return { error: errors.invalidUpdaterSig[locale], code: 'SIGNATURE_INVALID' }; } if (updaterPk && updaterPk !== signerPk) { return { error: errors.invalidUpdaterDid[locale], code: 'UPDATER_MISMATCH' }; } const hash = (0, object_hash_1.default)(body); const decoded = (0, jwt_1.decode)(signerToken); if (decoded.hash !== hash) { logger.debug('hash mismatch', decoded.hash, hash, body, decoded); return { error: errors.invalidPayloadHash[locale], code: 'PAYLOAD_HASH_MISMATCH' }; } return { error: '', code: 'OK' }; }; const handleSessionCreate = async (context) => { if (isValidContext(context) === false) { return { error: errors.invalidContext[context.locale], code: 'CONTEXT_INVALID' }; } const { sessionId, updaterPk, strategy = 'default', authUrl, connectUrl = '', approveUrl = '', autoConnect = true, forceConnected = true, onlyConnect = false, requestedClaims = [], timeout, } = context.body; if (sessionId.length !== 21) { return { error: 'Invalid sessionId', code: 'SESSION_ID_INVALID' }; } let result = verifyUpdater(context); if (result.error) { return result; } const session = { sessionId, status: 'created', updaterPk, strategy: onlyConnect && strategy === 'smart' ? 'default' : strategy, authUrl, connectUrl, approveUrl, challenge: (0, util_1.getStepChallenge)(), autoConnect, forceConnected, withinSession: !!context.previousConnected && context.headers['x-user-did'] === context.previousConnected.userDid, onlyConnect, appInfo: await authenticator.getAppInfo({ ...context, baseUrl: new URL(authUrl).origin }), previousConnected: context.previousConnected, currentConnected: null, currentStep: 0, requestedClaims, responseClaims: [], approveResults: [], timeout, error: '', }; if (Array.isArray(session.requestedClaims)) { result = (0, types_1.isRequestList)(session.requestedClaims); if (result.error) { return result; } } const { value, error } = types_1.Session.validate(session); if (error) { return { error: (0, types_1.t)(errors.invalidSessionProp[context.locale], { error: error.details.map((x) => x.message).join(', '), }), code: 'SESSION_UPDATE_INVALID', }; } logger.debug('session.created', sessionId); return storage.create(sessionId, value); }; const handleSessionRead = (sessionId) => { return storage.read(sessionId); }; const handleSessionUpdate = async (context) => { const { locale, body, session, sessionId } = context; try { if (isValidContext(context) === false) { throw new types_1.CustomError('CONTEXT_INVALID', errors.invalidContext[locale]); } if (storage.isFinalized(session.status)) { throw new types_1.CustomError('SESSION_FINALIZED', (0, types_1.t)(errors.sessionFinalized[locale], { status: `${session.status}${session.error ? `: ${session.error}` : ''}`, })); } let result = verifyUpdater(context, session.updaterPk); if (result.error) { throw new types_1.CustomError(result.code, result.error); } if (body.status && ['error', 'canceled'].includes(body.status) === false) { throw new types_1.CustomError('SESSION_STATUS_INVALID', errors.sessionStatusInvalid[locale]); } if (Array.isArray(body.requestedClaims)) { result = (0, types_1.isRequestList)(body.requestedClaims); if (result.error) { throw new types_1.CustomError(result.code, result.error); } } const { error, value } = types_1.Session.validate({ ...session, ...body }); if (error) { throw new types_1.CustomError('SESSION_UPDATE_INVALID', (0, types_1.t)(errors.invalidSessionUpdate[locale], { error: error.details.map((x) => x.message).join(', ') })); } const updates = (0, pick_1.default)(value, ['error', 'status', 'approveResults', 'requestedClaims']); logger.info('update session', context.sessionId, updates); return await storage.update(context.sessionId, updates); } catch (err) { logger.error(err); wsServer.broadcast(sessionId, { status: 'error', error: err.message }); await storage.update(sessionId, { status: 'error', error: err.message }); return { error: err.message, code: err.code }; } }; const handleSessionDelete = async (context) => { const { session, sessionId } = context; const result = verifyUpdater(context, session.updaterPk); if (result.error) { return result; } await storage.delete(sessionId); return { code: 'OK' }; }; const getAuthPrincipalRequest = (session) => { const { strategy, previousConnected, forceConnected, withinSession, onlyConnect } = session; let description = 'Select an account to continue'; let target = ''; let supervised = false; if (onlyConnect || !previousConnected) { description = (0, did_1.isValid)(strategy) ? 'Select following account to continue' : 'Select an account to continue'; target = (0, did_1.isValid)(strategy) ? strategy : ''; supervised = true; } else if (forceConnected && withinSession && previousConnected) { description = 'Select following account to continue'; target = previousConnected.userDid; supervised = false; } return { type: 'authPrincipal', description, target, supervised, }; }; const waitForSession = async (sessionId, timeout, checkFn, reason, locale) => { let session = {}; try { await (0, p_wait_for_1.default)(async () => { session = await storage.read(sessionId); if (session.status === 'error') { throw new types_1.CustomError('AppError', session.error); } if (session.status === 'canceled') { throw new types_1.CustomError('AppCanceled', errors.userCanceled[locale]); } return checkFn(session); }, { interval: 200, timeout }); return await storage.read(sessionId); } catch (err) { if (session && ['error', 'canceled'].includes(session.status) === false) { throw new types_1.CustomError('TimeoutError', `${reason} within ${timeout}ms`); } throw err; } }; const waitForAppConnect = (sessionId, timeout, locale) => waitForSession(sessionId, timeout, (x) => x.requestedClaims.length > 0, 'Requested claims not provided by app', locale); const waitForAppApprove = (sessionId, timeout, locale) => waitForSession(sessionId, timeout, (x) => Array.isArray(x.approveResults) && typeof x.approveResults[x.currentStep] !== 'undefined', 'Response claims not handled by app', locale); const fetchRequestList = async (session, locale) => { try { const result = await axios_1.default.post(session.connectUrl, { ...(0, pick_1.default)(session, ['sessionId', 'authUrl', 'currentConnected', 'previousConnected']), locale, }, { timeout: session.timeout.app }); if (result.data.error) { throw new types_1.CustomError('AppError', result.data.error); } const { code, error } = (0, types_1.isRequestList)(result.data); if (code !== 'OK') { throw new types_1.CustomError('AppError', error); } return result.data; } catch (err) { if (err.response) { console.warn(err.response); } throw new types_1.CustomError('AppError', (0, types_1.t)(errors.invalidConnectUrl[locale], { url: session.connectUrl, error: err.message })); } }; const fetchApproveResult = async (session, locale) => { try { const result = await axios_1.default.post(session.approveUrl, { ...(0, pick_1.default)(session, [ 'sessionId', 'authUrl', 'challenge', 'currentStep', 'currentConnected', 'previousConnected', 'requestedClaims', 'responseClaims', 'approveResults', ]), locale, }, { timeout: session.timeout.app }); return result.data; } catch (err) { if (err.response) { console.warn(err.response); } throw new types_1.CustomError('AppError', (0, types_1.t)(errors.invalidApproveUrl[locale], { url: session.approveUrl, error: err.message })); } }; const ensureAppConnected = async (session, locale) => { let newSession; // If our claims are populated already, move to appConnected without waiting if (session.requestedClaims.length > 0) { newSession = await storage.update(session.sessionId, { status: 'appConnected' }); } else if (session.connectUrl) { // If we should fetch claims from some url, fetch and verify const requestedClaims = await fetchRequestList(session, locale); newSession = await storage.update(session.sessionId, { status: 'appConnected', requestedClaims }); } else { // else wait for webapp to fill the claims newSession = await waitForAppConnect(session.sessionId, session.timeout.app, locale); await storage.update(session.sessionId, { status: 'appConnected' }); } wsServer.broadcast(session.sessionId, { status: 'appConnected', requestedClaims: newSession.requestedClaims }); logger.debug('session.appConnected', session.sessionId); return newSession; }; const handleClaimRequest = async (context) => { const { sessionId, session, didwallet, locale } = context; try { if (isValidContext(context) === false) { throw new types_1.CustomError('CONTEXT_INVALID', errors.invalidContext[locale]); } if (storage.isFinalized(session.status)) { throw new types_1.CustomError('SESSION_FINALIZED', (0, types_1.t)(errors.sessionFinalized[locale], { status: `${session.status}${session.error ? `: ${session.error}` : ''}`, })); } // if we are in created status, if (session.status === 'created') { logger.debug('session.walletScanned', sessionId); wsServer.broadcast(sessionId, { status: 'walletScanned', didwallet }); await storage.update(sessionId, { status: 'walletScanned' }); // return authPrincipal claim return signClaims([getAuthPrincipalRequest(session)], context); } // else we should perform a step by step style return signClaims(session.requestedClaims[session.currentStep], context); } catch (err) { logger.error(err); wsServer.broadcast(sessionId, { status: 'error', error: err.message }); await storage.update(sessionId, { status: 'error', error: err.message }); return signJson({ error: err.message }, context); } }; const handleClaimResponse = async (context) => { const { sessionId, session, body, locale, didwallet } = context; try { if (isValidContext(context) === false) { throw new types_1.CustomError('CONTEXT_INVALID', errors.invalidContext[locale]); } if (storage.isFinalized(session.status)) { throw new types_1.CustomError('SESSION_FINALIZED', (0, types_1.t)(errors.sessionFinalized[locale], { status: `${session.status}${session.error ? `: ${session.error}` : ''}`, })); } const { userDid, userPk, action, challenge, claims } = await authenticator.verify(body, locale); // Ensure user approval if (action === 'declineAuth') { throw new types_1.CustomError('RejectError', errors.userDeclined[locale]); } // Ensure challenge match if (challenge !== session.challenge) { throw new Error(errors.challengeMismatch[locale]); } const handleWalletApprove = async () => { // @ts-ignore const justifiedClaims = ((0, isEmpty_1.default)(claims) ? [{ type: 'authPrincipal' }] : claims).map((x) => { // NOTE: this is required to support legacy android wallet if ((0, isEmpty_1.default)(x)) { return { type: 'authPrincipal', ...(session.currentConnected || { userDid, userPk }), }; } if (x.type === 'authPrincipal') { return { ...x, ...(session.currentConnected || { userDid, userPk }), }; } return x; }); logger.info('session.walletApproved', sessionId, justifiedClaims); const updated = await storage.update(sessionId, { status: 'walletApproved', responseClaims: [...session.responseClaims, justifiedClaims], }); wsServer.broadcast(sessionId, { status: 'walletApproved', responseClaims: justifiedClaims, currentStep: session.currentStep, challenge: session.challenge, }); // If we should fetch response from some url, fetch and verify if (session.approveUrl) { const approveResults = [...session.approveResults, await fetchApproveResult(updated, locale)]; wsServer.broadcast(sessionId, { status: 'appApproved', approveResults }); return storage.update(sessionId, { status: 'appApproved', approveResults }); } // Otherwise wait for webapp to fill the approve results await waitForAppApprove(sessionId, session.timeout.app, locale); wsServer.broadcast(sessionId, { status: 'appApproved' }); return storage.update(sessionId, { status: 'appApproved' }); }; let newSession; // If wallet is submitting authPrincipal claim, // move to walletConnected and wait for appConnected // once appConnected we return the first claim if (session.status === 'walletScanned') { logger.debug('session.walletConnected', sessionId); newSession = await storage.update(sessionId, { status: 'walletConnected', currentConnected: { userDid, userPk, didwallet }, }); wsServer.broadcast(sessionId, { status: 'walletConnected', currentConnected: { userDid, userPk, didwallet }, }); // If this is a connect-only session, end it: walletApprove --> appApprove --> complete if (session.onlyConnect) { newSession = await handleWalletApprove(); logger.debug('session.completed', sessionId); await storage.update(sessionId, { status: 'completed' }); wsServer.broadcast(sessionId, { status: 'completed' }); return signJson(newSession.approveResults[session.currentStep], context); } newSession = await ensureAppConnected(newSession, locale); return signClaims(newSession.requestedClaims[session.currentStep], { ...context, session: newSession }); } // Ensure submitted claims match with requested const responseTypes = claims.map((x) => x.type); const requestedTypes = session.requestedClaims[session.currentStep].map((x) => x.type); if ((0, isEqual_1.default)(responseTypes, requestedTypes) === false) { throw new Error(errors.claimMismatch[locale]); } // Move to walletApproved state and wait for appApproved newSession = await handleWalletApprove(); // Return result if we've done const isDone = session.currentStep === session.requestedClaims.length - 1; if (isDone) { logger.debug('session.completed', sessionId); await storage.update(sessionId, { status: 'completed' }); wsServer.broadcast(sessionId, { status: 'completed' }); return signJson(newSession.approveResults[session.currentStep], context); } // Move on to next step if we are not the last step logger.debug('session.nextClaim', sessionId); const nextStep = session.currentStep + 1; const nextChallenge = (0, util_1.getStepChallenge)(); newSession = await storage.update(sessionId, { currentStep: nextStep, challenge: nextChallenge }); return signClaims(session.requestedClaims[nextStep], { ...context, session: newSession }); } catch (err) { logger.error(err); // error reported by dapp if (err.code === 'AppError') { logger.debug('session.error', sessionId); wsServer.broadcast(sessionId, { status: 'error', error: err.message, source: 'app' }); return signJson({ error: err.message }, context); } // error reported by dapp if (err.code === 'AppCanceled') { logger.debug('session.canceled', sessionId); wsServer.broadcast(sessionId, { status: 'canceled', error: err.message, source: 'app' }); return signJson({ error: err.message }, context); } // timeout error if (err.code === 'TimeoutError') { logger.debug('session.timeout', sessionId); await storage.update(sessionId, { status: 'timeout', error: err.message }); wsServer.broadcast(sessionId, { status: 'timeout', error: err.message, source: 'timer' }); return signJson({ error: err.message }, context); } // reject error if (err.code === 'RejectError') { logger.debug('session.rejected', sessionId); await storage.update(sessionId, { status: 'rejected', error: err.message }); wsServer.broadcast(sessionId, { status: 'rejected', error: err.message, source: 'wallet' }); return signJson({}, context); } // anything else logger.debug('session.error', sessionId); await storage.update(sessionId, { status: 'error', error: err.message }); wsServer.broadcast(sessionId, { status: 'error', error: err.message, code: err.code }); return signJson({ error: err.message }, context); } }; return { handleSessionCreate, handleSessionRead, handleSessionUpdate, handleSessionDelete, handleClaimRequest, handleClaimResponse, parseWalletUA: util_1.parseWalletUA, wsServer, }; } exports.createHandlers = createHandlers;