@did-connect/handler
Version:
Abstract handler for did-connect relay server
538 lines (537 loc) • 25.1 kB
JavaScript
"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;