From 9aedbb50e0f035c2922818674b29fa6be037e3e4 Mon Sep 17 00:00:00 2001 From: Daniil T Date: Tue, 20 Dec 2022 13:33:36 +0100 Subject: [PATCH 1/6] feat: digital signature #132 --- examples/restful/developer/keyManagement.ts | 13 ++ examples/restful/sell/finances.ts | 10 ++ ...ingleItem.ts => shopping.GetSingleItem.ts} | 0 examples/traditional/trading.GetAccount.ts | 12 ++ package-lock.json | 18 +++ package.json | 1 + rollup.config.js | 11 +- src/api/digitalSignature.ts | 138 ++++++++++++++++++ src/api/index.ts | 25 ++++ .../restful/developer/keyManagement/index.ts | 9 +- src/api/restful/index.ts | 57 +++++--- src/api/traditional/XMLRequest.ts | 14 +- src/api/traditional/index.ts | 52 ++++--- src/eBayApi.ts | 13 +- src/errors/index.ts | 4 + src/types/apiTypes.ts | 9 ++ src/types/traditonalTypes.ts | 3 +- test/api/digitalSignature.spec.ts | 54 +++++++ test/api/factory.spec.ts | 8 +- test/api/restful/restful.spec.ts | 80 +++++----- 20 files changed, 437 insertions(+), 94 deletions(-) create mode 100644 examples/restful/developer/keyManagement.ts create mode 100644 examples/restful/sell/finances.ts rename examples/traditional/{trading.GetSingleItem.ts => shopping.GetSingleItem.ts} (100%) create mode 100644 examples/traditional/trading.GetAccount.ts create mode 100644 src/api/digitalSignature.ts create mode 100644 test/api/digitalSignature.spec.ts diff --git a/examples/restful/developer/keyManagement.ts b/examples/restful/developer/keyManagement.ts new file mode 100644 index 0000000..30a0657 --- /dev/null +++ b/examples/restful/developer/keyManagement.ts @@ -0,0 +1,13 @@ +// tslint:disable:no-console +import eBayApi from '../../../src/eBayApi.js'; + +const eBay = eBayApi.fromEnv(); + +(async () => { + try { + const signingKey = await eBay.developer.keyManagement.createSigningKey('ED25519'); + console.log(JSON.stringify(signingKey, null, 2)); + } catch (e) { + console.error(e); + } +})(); diff --git a/examples/restful/sell/finances.ts b/examples/restful/sell/finances.ts new file mode 100644 index 0000000..b51beec --- /dev/null +++ b/examples/restful/sell/finances.ts @@ -0,0 +1,10 @@ +// tslint:disable:no-console +import eBayApi from '../../../src/eBayApi.js'; + +const eBay = eBayApi.fromEnv(); + +eBay.sell.finances.sign.getSellerFundsSummary().then(result => { + console.log('result', JSON.stringify(result, null, 2)); +}).catch(e => { + console.error(JSON.stringify(e, null, 2)); +}); diff --git a/examples/traditional/trading.GetSingleItem.ts b/examples/traditional/shopping.GetSingleItem.ts similarity index 100% rename from examples/traditional/trading.GetSingleItem.ts rename to examples/traditional/shopping.GetSingleItem.ts diff --git a/examples/traditional/trading.GetAccount.ts b/examples/traditional/trading.GetAccount.ts new file mode 100644 index 0000000..2b99c92 --- /dev/null +++ b/examples/traditional/trading.GetAccount.ts @@ -0,0 +1,12 @@ +// tslint:disable:no-console +import eBayApi from '../../src/eBayApi.js'; + +const eBay = eBayApi.fromEnv(); +eBay.trading.GetAccount(null, { + sign: true +}).then(result => { + console.log(JSON.stringify(result, null, 2)); +}).catch(e => { + console.error(e); +}); + diff --git a/package-lock.json b/package-lock.json index 59f4ef1..3db8d65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@rollup/plugin-json": "^5.0.2", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-terser": "^0.2.0", + "@rollup/plugin-virtual": "^3.0.1", "@types/chai": "^4.3.4", "@types/debug": "^4.1.7", "@types/mocha": "^10.0.1", @@ -481,6 +482,23 @@ } } }, + "node_modules/@rollup/plugin-virtual": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.1.tgz", + "integrity": "sha512-fK8O0IL5+q+GrsMLuACVNk2x21g3yaw+sG2qn16SnUd3IlBsQyvWxLMGHmCmXRMecPjGRSZ/1LmZB4rjQm68og==", + "dev": true, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", diff --git a/package.json b/package.json index 41fc8ad..9b7e40d 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "@rollup/plugin-json": "^5.0.2", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-terser": "^0.2.0", + "@rollup/plugin-virtual": "^3.0.1", "@types/chai": "^4.3.4", "@types/debug": "^4.1.7", "@types/mocha": "^10.0.1", diff --git a/rollup.config.js b/rollup.config.js index 8328c2a..2f01299 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -3,6 +3,7 @@ import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; import bundleSize from 'rollup-plugin-bundle-size'; +import virtual from '@rollup/plugin-virtual'; const pkg = require('./package.json'); @@ -27,6 +28,9 @@ export default [{ name: 'eBayApi', exports: 'default', sourcemap: false, + globals: { + crypto: 'crypto' + } }, ], plugins @@ -40,5 +44,10 @@ export default [{ }, ], context: 'window', - plugins + plugins: [ + virtual({ + crypto: `export function createHash() { return window.crypto.createHash(...arguments); }; export function sign() { return window.crypto.sign(...arguments); };`, + }), + ...plugins + ] }] diff --git a/src/api/digitalSignature.ts b/src/api/digitalSignature.ts new file mode 100644 index 0000000..f4769f1 --- /dev/null +++ b/src/api/digitalSignature.ts @@ -0,0 +1,138 @@ +import {createHash, sign} from 'crypto'; +import {Cipher, Headers} from '../types/index.js'; + +const beginPrivateKey = '-----BEGIN PRIVATE KEY-----'; +const endPrivateKey = '-----END PRIVATE KEY-----'; + +// based on https://github.com/ebay/digital-signature-nodejs-sdk + +/** + * Returns the current UNIX timestamp. + * + * @returns {number} The unix timestamp. + */ +const getUnixTimestamp = (): number => Date.now() / 1000 | 0; + +const getSignatureParams = (payload: any) => [ + ...payload ? ['content-digest'] : [], + 'x-ebay-signature-key', + '@method', + '@path', + '@authority' +]; + +const getSignatureParamsValue = (payload: any) => getSignatureParams(payload).map(param => `"${param}"`).join(' '); + +/** + * Generates the 'Content-Digest' header value for the input payload. + * + * @param {any} payload The request payload. + * @param {string} cipher The algorithm used to calculate the digest. + * @returns {string} contentDigest The 'Content-Digest' header value. + */ +export const generateContentDigestValue = (payload: unknown, cipher: Cipher = 'sha256'): string => { + const payloadBuffer: Buffer = Buffer.from(typeof payload === 'string' ? payload : JSON.stringify(payload)); + + const hash = createHash(cipher).update(payloadBuffer).digest('base64'); + const algo: string = cipher === 'sha512' ? 'sha-512' : 'sha-256'; + return `${algo}=:${hash}:`; +}; + +export type SignatureComponents = { + method: string + authority: string // the host + path: string +} + +/** + * Generates the base string. + * + * @param {any} headers The HTTP request headers. + * @param {SignatureComponents} signatureComponents The config. + * @param {any} payload The payload. + * @param {number} timestamp The timestamp. + * @returns {string} payload The base string. + */ +export function generateBaseString(headers: Headers, signatureComponents: SignatureComponents, payload: any, timestamp = getUnixTimestamp()): string { + try { + let baseString: string = ''; + const signatureParams: string[] = getSignatureParams(payload); + + signatureParams.forEach(param => { + baseString += `"${param.toLowerCase()}": `; + + if (param.startsWith('@')) { + switch (param.toLowerCase()) { + case '@method': + baseString += signatureComponents.method; + break; + case '@authority': + baseString += signatureComponents.authority; + break; + case '@path': + baseString += signatureComponents.path; + break; + default: + throw new Error('Unknown pseudo header ' + param); + } + } else { + if (!headers[param]) { + throw new Error('Header ' + param + ' not included in message'); + } + baseString += headers[param]; + } + + baseString += '\n'; + }); + + baseString += `"@signature-params": (${getSignatureParamsValue(payload)});created=${timestamp}`; + + return baseString; + } catch (ex: any) { + throw new Error(`Error calculating signature base: ${ex.message}`); + } +} + +/** + * Generates the Signature-Input header value for the input payload. + * + * @param {any} payload The input config. + * @param {number} timestamp The timestamp. + * @returns {string} the 'Signature-Input' header value. + */ +export const generateSignatureInput = (payload: any, timestamp = getUnixTimestamp()): string => `sig1=(${getSignatureParamsValue(payload)});created=${timestamp}`; + +/** + * Generates the 'Signature' header. + * + * @param {any} headers The HTTP headers. + * @param {string} privateKey The HTTP headers. + * @param {SignatureComponents} signatureComponents The signature components + * @param {any} payload The payload + * @param {number} timestamp The payload + * @returns {string} the signature header value. + */ +export function generateSignature( + headers: any, + privateKey: string, + signatureComponents: SignatureComponents, + payload: any, + timestamp = getUnixTimestamp() +): string { + const baseString = generateBaseString(headers, signatureComponents, payload, timestamp); + + privateKey = privateKey.trim(); + if (!privateKey.startsWith(beginPrivateKey)) { + privateKey = beginPrivateKey + '\n' + privateKey + '\n' + endPrivateKey; + } + + const signatureBuffer = sign( + undefined, // If algorithm is undefined, then it is dependent upon the private key type. + Buffer.from(baseString), + privateKey + ); + + const signature = signatureBuffer.toString('base64'); + return `sig1=:${signature}:`; +} + diff --git a/src/api/index.ts b/src/api/index.ts index 9bf9ab3..533b8df 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,6 +2,12 @@ import Auth from '../auth/index.js'; import {IEBayApiRequest} from '../request.js'; import {AppConfig} from '../types/index.js'; import Base from './base.js'; +import { + generateContentDigestValue, + generateSignature, + generateSignatureInput, + SignatureComponents +} from './digitalSignature.js'; /** * Superclass with Auth container. @@ -18,4 +24,23 @@ export default abstract class Api extends Base { this.auth = auth || new Auth(this.config, this.req); } + getDigitalSignatureHeaders(signatureComponents: SignatureComponents, payload: any) { + if (!this.config.signature) { + return {}; + } + + const digitalSignatureHeaders = { + 'x-ebay-enforce-signature': true, // enable digital signature validation + 'x-ebay-signature-key': this.config.signature.jwe, // always contains JWE + ...payload ? { + 'content-digest': generateContentDigestValue(payload, this.config.signature.cipher ?? 'sha256') + } : {}, + 'signature-input': generateSignatureInput(payload) + }; + + return { + ...digitalSignatureHeaders, + 'signature': generateSignature(digitalSignatureHeaders, this.config.signature.privateKey, signatureComponents, payload) + }; + } } diff --git a/src/api/restful/developer/keyManagement/index.ts b/src/api/restful/developer/keyManagement/index.ts index 96595f2..2c3819f 100644 --- a/src/api/restful/developer/keyManagement/index.ts +++ b/src/api/restful/developer/keyManagement/index.ts @@ -24,13 +24,14 @@ export default class KeyManagement extends Restful { } /** - * his method creates keypairs. + * This method creates keypairs. */ - public createSigningKey(data: { signingKeyCipher: string }) { - return this.post(`/signing_key`, data); + public createSigningKey(signingKeyCipher: 'ED25519' | 'RSA') { + return this.post(`/signing_key`, { + signingKeyCipher + }); } - /** * This method returns the Public Key, Public Key as JWE, * and metadata for a specified signingKeyId associated with the application key making the call. diff --git a/src/api/restful/index.ts b/src/api/restful/index.ts index 9c5c07e..3ccba57 100644 --- a/src/api/restful/index.ts +++ b/src/api/restful/index.ts @@ -1,8 +1,8 @@ -import Api from '../index.js'; import Auth from '../../auth/index.js'; import {EBayInvalidAccessToken, handleEBayError} from '../../errors/index.js'; import {IEBayApiRequest} from '../../request.js'; import {ApiRequestConfig, AppConfig} from '../../types/index.js'; +import Api from '../index.js'; export const defaultApiHeaders: Record = { 'Content-Type': 'application/json', @@ -23,6 +23,7 @@ const additionalHeaders: Record = { export type RestfulApiConfig = { subdomain?: string useIaf?: boolean + sign?: boolean apiVersion?: string basePath?: string schema?: string @@ -32,7 +33,7 @@ export type RestfulApiConfig = { export type ApiRequest = { method: keyof IEBayApiRequest, - url: string, + path: string, config?: any, // AxiosConfig data?: any, } @@ -100,7 +101,8 @@ export default abstract class Restful extends Api { sandbox: this.config.sandbox, tld: 'ebay.com', headers: {}, - returnResponse: false + returnResponse: false, + sign: false }; } @@ -131,20 +133,27 @@ export default abstract class Restful extends Api { return this.api({subdomain: 'apiz'}); } - public async get(url: string, config: any = {}, apiConfig?: RestfulApiConfig) { - return this.doRequest({method: 'get', url, config}, apiConfig); + /** + * Sign request + */ + get sign() { + return this.api({sign: true}); + } + + public async get(path: string, config: any = {}, apiConfig?: RestfulApiConfig) { + return this.doRequest({method: 'get', path, config}, apiConfig); } - public async delete(url: string, config: any = {}, apiConfig?: RestfulApiConfig) { - return this.doRequest({method: 'delete', url, config}, apiConfig); + public async delete(path: string, config: any = {}, apiConfig?: RestfulApiConfig) { + return this.doRequest({method: 'delete', path, config}, apiConfig); } - public async post(url: string, data?: any, config: any = {}, apiConfig?: RestfulApiConfig) { - return this.doRequest({method: 'post', url, data, config}, apiConfig); + public async post(path: string, data?: any, config: any = {}, apiConfig?: RestfulApiConfig) { + return this.doRequest({method: 'post', path, data, config}, apiConfig); } - public async put(url: string, data?: any, config: any = {}, apiConfig?: RestfulApiConfig) { - return this.doRequest({method: 'put', url, data, config}, apiConfig); + public async put(path: string, data?: any, config: any = {}, apiConfig?: RestfulApiConfig) { + return this.doRequest({method: 'put', path, data, config}, apiConfig); } get additionalHeaders() { @@ -158,20 +167,30 @@ export default abstract class Restful extends Api { }, {}); } - public async enrichRequestConfig(config: any = {}, apiConfig: Required = this.apiConfig) { + public async enrichRequestConfig( + apiRequest: ApiRequest, + payload: any = null, + apiConfig: Required = this.apiConfig) { const authHeader = await this.auth.getHeaderAuthorization(apiConfig.useIaf); + const signatureHeaders = apiConfig.sign ? this.getDigitalSignatureHeaders({ + method: apiRequest.method.toUpperCase(), + authority: Restful.buildServerUrl('', apiConfig.subdomain, apiConfig.sandbox, apiConfig.tld), + path: apiConfig.apiVersion + apiConfig.basePath + apiRequest.path + }, payload) : {}; + const headers = { ...defaultApiHeaders, ...this.additionalHeaders, ...authHeader, - ...apiConfig.headers + ...apiConfig.headers, + ...signatureHeaders }; return { - ...config, + ...apiRequest.config, headers: { - ...(config.headers || {}), + ...(apiRequest.config.headers || {}), ...headers } }; @@ -207,24 +226,24 @@ export default abstract class Restful extends Api { apiConfig: RestfulApiConfig = this.apiConfig, refreshToken = false, ): Promise { - const {url, method, data, config} = apiRequest; + const {path, method, data} = apiRequest; const apiCfg: Required = {...this.apiConfig, ...apiConfig}; - const endpoint = this.getServerUrl(apiCfg) + url; + const endpoint = this.getServerUrl(apiCfg) + path; try { if (refreshToken) { await this.auth.OAuth2.refreshToken(); } - const enrichedConfig = await this.enrichRequestConfig(config, apiCfg); + const enrichedConfig = await this.enrichRequestConfig(apiRequest, data, apiCfg); const args = ['get', 'delete'].includes(method) ? [enrichedConfig] : [data, enrichedConfig]; // @ts-ignore const response = await this.req[method](endpoint, ...args); if (this.apiConfig.returnResponse) { - return response + return response; } else { return response.data; } diff --git a/src/api/traditional/XMLRequest.ts b/src/api/traditional/XMLRequest.ts index e777d80..c390508 100644 --- a/src/api/traditional/XMLRequest.ts +++ b/src/api/traditional/XMLRequest.ts @@ -1,5 +1,5 @@ import debug from 'debug'; -import {XMLParser, XMLBuilder} from 'fast-xml-parser'; +import {XMLBuilder, XMLParser} from 'fast-xml-parser'; import {checkEBayResponse, EbayNoCallError} from '../../errors/index.js'; import {IEBayApiRequest} from '../../request.js'; import {ApiRequestConfig, Headers} from '../../types/index.js'; @@ -44,19 +44,22 @@ export type TraditionalApiConfig = { raw?: boolean, parseOptions?: object, useIaf?: boolean, + sign?: boolean, hook?: (xml: string) => BodyHeaders } & ApiRequestConfig; export type XMLReqConfig = TraditionalApiConfig & { - endpoint: string, - xmlns: string, - eBayAuthToken?: string | null, + endpoint: string + xmlns: string + eBayAuthToken?: string | null + digitalSignatureHeaders?: (payload: any) => Headers }; export const defaultApiConfig: Required> = { raw: false, parseOptions: defaultXML2JSONParseOptions, useIaf: true, + sign: false, headers: {}, returnResponse: false }; @@ -85,7 +88,7 @@ export default class XMLRequest { * @param {Object} req the request * @param {XMLReqConfig} config */ - constructor(callName: string, fields: Fields, config: XMLReqConfig, req: IEBayApiRequest) { + constructor(callName: string, fields: Fields | null, config: XMLReqConfig, req: IEBayApiRequest) { if (!callName) { throw new EbayNoCallError(); } @@ -181,6 +184,7 @@ export default class XMLRequest { const config = { headers: { ...this.getHeaders(), + ...this.config.digitalSignatureHeaders ? this.config.digitalSignatureHeaders(body) : {}, ...(headers ? headers : {}) } }; diff --git a/src/api/traditional/index.ts b/src/api/traditional/index.ts index 21c0038..04bc7aa 100644 --- a/src/api/traditional/index.ts +++ b/src/api/traditional/index.ts @@ -1,33 +1,30 @@ import {stringify} from 'qs'; -import Api from '../index.js'; import {EBayIAFTokenExpired, EBayIAFTokenInvalid, handleEBayError} from '../../errors/index.js'; import {ClientAlerts, Finding, Merchandising, Shopping, Trading, TraditionalApi} from '../../types/index.js'; +import Api from '../index.js'; import ClientAlertsCalls from './clientAlerts/index.js'; import {Fields} from './fields.js'; import FindingCalls from './finding/index.js'; import MerchandisingCalls from './merchandising/index.js'; import ShoppingCalls from './shopping/index.js'; import TradingCalls from './trading/index.js'; -import XMLRequest, {defaultApiConfig, TraditionalApiConfig} from './XMLRequest.js'; +import XMLRequest, {defaultApiConfig, TraditionalApiConfig, XMLReqConfig} from './XMLRequest.js'; /** * Traditional eBay API. */ export default class Traditional extends Api { public createTradingApi(): Trading { - if (!this.config.devId) { - throw new Error('devId is required for trading API.'); - } - if (typeof this.config.siteId !== 'number') { throw new Error('siteId is required for trading API.'); } return this.createTraditionalXMLApi({ endpoint: { - production: 'https://api.ebay.com/ws/api.dll', - sandbox: 'https://api.sandbox.ebay.com/ws/api.dll' + production: 'api.ebay.com', + sandbox: 'api.sandbox.ebay.com' }, + path: '/ws/api.dll', calls: TradingCalls, xmlns: 'urn:ebay:apis:eBLBaseComponents', headers: (callName: string, accessToken?: string | null) => ({ @@ -48,9 +45,10 @@ export default class Traditional extends Api { } return this.createTraditionalXMLApi({ endpoint: { - production: 'https://open.api.ebay.com/shopping', - sandbox: 'https://open.api.sandbox.ebay.com/shopping' + production: 'open.api.ebay.com', + sandbox: 'open.api.sandbox.ebay.com' }, + path: '/shopping', xmlns: 'urn:ebay:apis:eBLBaseComponents', calls: ShoppingCalls, headers: (callName: string, accessToken?: string | null) => ({ @@ -67,9 +65,10 @@ export default class Traditional extends Api { public createFindingApi(): Finding { return this.createTraditionalXMLApi({ endpoint: { - production: 'https://svcs.ebay.com/services/search/FindingService/v1', - sandbox: 'https://svcs.sandbox.ebay.com/services/search/FindingService/v1' + production: 'svcs.ebay.com', + sandbox: 'svcs.sandbox.ebay.com' }, + path: '/services/search/FindingService/v1', xmlns: 'http://www.ebay.com/marketplace/search/v1/services', calls: FindingCalls, headers: (callName: string) => ({ @@ -85,9 +84,10 @@ export default class Traditional extends Api { } const api = { endpoint: { - production: 'https://clientalerts.ebay.com/ws/ecasvc/ClientAlerts', - sandbox: 'https://clientalerts.sandbox.ebay.com/ws/ecasvc/ClientAlerts' + production: 'clientalerts.ebay.com', + sandbox: 'clientalerts.sandbox.ebay.com' }, + path: '/ws/ecasvc/ClientAlerts', calls: ClientAlertsCalls }; @@ -126,9 +126,10 @@ export default class Traditional extends Api { public createMerchandisingApi(): Merchandising { return this.createTraditionalXMLApi({ endpoint: { - production: 'https://svcs.ebay.com/MerchandisingService', - sandbox: 'https://svcs.sandbox.ebay.com/MerchandisingService' + production: 'svcs.ebay.com', + sandbox: 'svcs.sandbox.ebay.com' }, + path: '/MerchandisingService', xmlns: 'http://www.ebay.com/marketplace/services', calls: MerchandisingCalls, headers: (callName: string) => ({ @@ -157,7 +158,7 @@ export default class Traditional extends Api { } }; - private async request(apiConfig: TraditionalApiConfig, api: TraditionalApi, callName: string, fields: Fields, refreshToken = false) { + private async request(apiConfig: TraditionalApiConfig, api: TraditionalApi, callName: string, fields: Fields | null, refreshToken = false) { try { if (refreshToken) { await this.auth.OAuth2.refreshToken(); @@ -172,20 +173,31 @@ export default class Traditional extends Api { } } - private async getConfig(api: TraditionalApi, callName: string, apiConfig: TraditionalApiConfig) { + private async getConfig(api: TraditionalApi, callName: string, apiConfig: TraditionalApiConfig): Promise { const eBayAuthToken = this.auth.authNAuth.eBayAuthToken; const accessToken = !eBayAuthToken && apiConfig.useIaf ? (await this.auth.OAuth2.getAccessToken()) : null; const useIaf = !eBayAuthToken && accessToken; + const host = this.config.sandbox ? api.endpoint.sandbox : api.endpoint.production; + return { ...apiConfig, xmlns: api.xmlns, - endpoint: api.endpoint[this.config.sandbox ? 'sandbox' : 'production'], + endpoint: `https://${host}${api.path}`, // always use https headers: { ...api.headers(callName, useIaf ? accessToken : null), ...apiConfig.headers }, - ...(!useIaf ? { eBayAuthToken } : {}) + digitalSignatureHeaders: payload => { + return apiConfig.sign ? this.getDigitalSignatureHeaders({ + method: 'POST', // it's always post + authority: host, + path: api.path + }, + payload + ) : {}; + }, + ...(!useIaf ? {eBayAuthToken} : {}) }; } diff --git a/src/eBayApi.ts b/src/eBayApi.ts index c1ce84f..db5d6e1 100644 --- a/src/eBayApi.ts +++ b/src/eBayApi.ts @@ -13,7 +13,7 @@ import * as errors from './errors/index.js'; import {ApiEnvError} from './errors/index.js'; import {IEBayApiRequest} from './request.js'; import * as types from './types/index.js'; -import {AppConfig, ClientAlerts, Finding, Keyset, Merchandising, Shopping, Trading} from './types/index.js'; +import {AppConfig, ClientAlerts, Finding, Keyset, Merchandising, Shopping, Signature, Trading} from './types/index.js'; const defaultConfig: Omit = { sandbox: false, @@ -46,6 +46,14 @@ export default class eBayApi extends Api { throw new ApiEnvError('EBAY_CERT_ID'); } + let signature: Signature | null = null; + if (process.env.EBAY_JWE && process.env.EBAY_PRIVATE_KEY) { + signature = { + jwe: process.env.EBAY_JWE, + privateKey: process.env.EBAY_PRIVATE_KEY + }; + } + return new eBayApi({ appId: process.env.EBAY_APP_ID, certId: process.env.EBAY_CERT_ID, @@ -56,7 +64,8 @@ export default class eBayApi extends Api { MarketplaceId[process.env.EBAY_MARKETPLACE_ID as keyof typeof MarketplaceId] as MarketplaceId : MarketplaceId.EBAY_US, ruName: process.env.EBAY_RU_NAME, - sandbox: (process.env.EBAY_SANDBOX === 'true') + sandbox: (process.env.EBAY_SANDBOX === 'true'), + signature }, req); } diff --git a/src/errors/index.ts b/src/errors/index.ts index 70f64cb..36d376e 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -135,6 +135,10 @@ export const mapEBayError = (err: any) => { message: data.error, description: data.error_description || '' }; + } else if (typeof data === 'string') { + eBayError = { + message: data + }; } else { eBayError = data; } diff --git a/src/types/apiTypes.ts b/src/types/apiTypes.ts index 9cb950f..05e1868 100644 --- a/src/types/apiTypes.ts +++ b/src/types/apiTypes.ts @@ -21,10 +21,19 @@ export type TraditionalConfig = { authToken?: string | null } +export type Cipher = 'sha256' | 'sha512'; + +export type Signature = { + cipher?: Cipher + jwe: string, // The value of the x-ebay-signature-key header is the Public Key as JWE value that has been created by the Key Management API. + privateKey: string +} + export type eBayConfig = Keyset & { sandbox: boolean, ruName?: string, scope?: Scope, + signature?: Signature | null } & TraditionalConfig & RestConfig; export type ApiConfig = { diff --git a/src/types/traditonalTypes.ts b/src/types/traditonalTypes.ts index 0458d0a..3c4ba0f 100644 --- a/src/types/traditonalTypes.ts +++ b/src/types/traditonalTypes.ts @@ -6,7 +6,7 @@ import ShoppingCalls from '../api/traditional/shopping/index.js'; import TradingCalls from '../api/traditional/trading/index.js'; import {TraditionalApiConfig} from '../api/traditional/XMLRequest.js'; -export type XMLApiCall = (fields?: Fields, apiConfig?: TraditionalApiConfig) => Promise; +export type XMLApiCall = (fields?: Fields | null, apiConfig?: TraditionalApiConfig) => Promise; export type Trading = { [key in typeof TradingCalls[number]]: XMLApiCall; @@ -36,6 +36,7 @@ type Endpoint = { export type TraditionalApi = { endpoint: Endpoint, xmlns: string, + path: string, calls: typeof TradingCalls | typeof ShoppingCalls | typeof FindingCalls | typeof ClientAlertsCalls | typeof MerchandisingCalls, headers: (callName: string, accessToken?: string | null) => object }; diff --git a/test/api/digitalSignature.spec.ts b/test/api/digitalSignature.spec.ts new file mode 100644 index 0000000..e6be352 --- /dev/null +++ b/test/api/digitalSignature.spec.ts @@ -0,0 +1,54 @@ +import {expect} from 'chai'; +import 'mocha'; +// @ts-ignore +import sinon from 'sinon'; +import {generateContentDigestValue, generateSignature, generateSignatureInput} from '../../src/api/digitalSignature.js'; + +const privateKey = ` +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF +-----END PRIVATE KEY-----`; + +describe('Digital Signature', () => { + it('should be able to generate \'signature-input\' header when request has payload', () => { + const signatureInput = generateSignatureInput({}, 123); + expect(signatureInput).to.eql(`sig1=("content-digest" "x-ebay-signature-key" "@method" "@path" "@authority");created=123`); + }); + + it('should be able to generate \'signature-input\' header when request has no payload', () => { + const signatureInput = generateSignatureInput(null, 123); + expect(signatureInput).to.eql(`sig1=("x-ebay-signature-key" "@method" "@path" "@authority");created=123`); + }); + + it('should be able to generate \'Signature\' header', () => { + const payload = '{"hello": "world"}'; + const contentDigest = generateContentDigestValue(payload); + expect(contentDigest).to.eql('sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:'); + const signature = generateSignature({ + 'content-digest': contentDigest, + 'signature-input': 'sig1=("content-digest" "x-ebay-signature-key" "@method" "@path" "@authority");created=1663459378', + 'x-ebay-signature-key': 'eyJhbGciOiJBMjU2R0NNS1ciLCJlbmMiOiJBMjU2R0NNIiwiemlwIjoiREVGIiwiaXYiOiJvSzFwdXJNVHQtci14VUwzIiwidGFnIjoiTjB4WjI4ZklZckFmYkd5UWFrTnpjZyJ9.AYdKU7ObIc7Z764OrlKpwUViK8Rphxl0xMP9v2_o9mI.1DbZiSQNRK6pLeIw.Yzp3IDV8RM_h_lMAnwGpMA4DXbaDdmqAh-65kO9xyDgzHD6s0kY3p-yO6oPR9kEcAbjGXIULeQKWVYzbfHKwXTY09Npj_mNuO5yxgZtWnL55uIgP2HL1So2dKkZRK0eyPa6DEXJT71lPtwZtpIGyq9R5h6s3kGMbqA.m4t_MX4VnlXJGx1X_zZ-KQ' + }, privateKey, { + method: 'POST', + authority: 'localhost:8080', + path: '/test' + }, payload, 1663459378); + expect(signature).to.eql('sig1=:gkk7dqudw21DFHDVBoRUWe/F6/2hTEmWRFDBxiN6COD4PjozXziiDFML1nFHu+0UcMXC/niltxzABjnugu4DCA==:'); + }); + + it('should throw an error if content-digest is not in the header', () => { + expect(() => generateSignature({}, privateKey, { + method: 'POST', + authority: 'localhost:8080', + path: '/test' + }, {}, 1663459378)).to.throw(/content-digest/); + }); + + it('should throw an error pseudo header is not known', () => { + expect(() => generateSignature({}, privateKey, { + method: 'POST', + authority: 'localhost:8080', + path: '/test' + }, {}, 1663459378)).to.throw(/content-digest/); + }); +}); diff --git a/test/api/factory.spec.ts b/test/api/factory.spec.ts index ce784f1..c848bc4 100644 --- a/test/api/factory.spec.ts +++ b/test/api/factory.spec.ts @@ -3,8 +3,8 @@ import 'mocha'; // @ts-ignore import sinon from 'sinon'; import ApiFactory from '../../src/api/apiFactory.js'; -import {eBayConfig} from '../../src/types/index.js'; import {IEBayApiRequest} from '../../src/request.js'; +import {eBayConfig} from '../../src/types/index.js'; describe('FactoryTest', () => { let config: eBayConfig; @@ -21,9 +21,9 @@ describe('FactoryTest', () => { config = {appId: 'appId', certId: 'certId', sandbox: true, siteId: 0, devId: 'devId'}; }); - it('Throws an error if devId is not defined', () => { - delete config.devId; + it('Throws an error if siteId is not defined', () => { + delete config.siteId; const factory = new ApiFactory(config, request); - expect(factory.createTradingApi.bind(factory)).to.throw(/devId/); + expect(factory.createTradingApi.bind(factory)).to.throw(/siteId/); }); }); diff --git a/test/api/restful/restful.spec.ts b/test/api/restful/restful.spec.ts index ae1f7c3..26fb419 100644 --- a/test/api/restful/restful.spec.ts +++ b/test/api/restful/restful.spec.ts @@ -1,7 +1,7 @@ // @ts-ignore +import {expect} from 'chai'; import sinon from 'sinon'; import Restful, {defaultApiHeaders} from '../../../src/api/restful/index.js'; -import {expect} from 'chai'; import {MarketplaceId} from '../../../src/enums/index.js'; class TestApi extends Restful { @@ -14,7 +14,7 @@ class TestApi extends Restful { headers: { 'X-TEST': 'X-TEST' } - }) + }); } } @@ -43,24 +43,24 @@ describe('Restful API', () => { put: sinon.stub(), post: sinon.stub(), postForm: sinon.stub().returns(Promise.resolve({ - data: { access_token: 'new_access_token'} + data: {access_token: 'new_access_token'} })), instance: sinon.stub() - } - }) + }; + }); describe('extend Restful API with additional parameters', () => { it('returns correct baseUrl', () => { - const api = new TestApi(config, req) + const api = new TestApi(config, req); const apix = new TestApi(config, req).apix; const apiz = new TestApi(config, req).apiz; const apiy = new TestApi(config, req).api({subdomain: 'apiy'}); - expect(api.baseUrl).to.equal('https://api.sandbox.ebay.com/basePath') - expect(apix.baseUrl).to.equal('https://apix.sandbox.ebay.com/basePath') - expect(apiz.baseUrl).to.equal('https://apiz.sandbox.ebay.com/basePath') - expect(apiy.baseUrl).to.equal('https://apiy.sandbox.ebay.com/basePath') - }) + expect(api.baseUrl).to.equal('https://api.sandbox.ebay.com/basePath'); + expect(apix.baseUrl).to.equal('https://apix.sandbox.ebay.com/basePath'); + expect(apiz.baseUrl).to.equal('https://apiz.sandbox.ebay.com/basePath'); + expect(apiy.baseUrl).to.equal('https://apiy.sandbox.ebay.com/basePath'); + }); it('extends headers', async () => { const post = sinon.stub().returns({item: '1'}); @@ -72,30 +72,34 @@ describe('Restful API', () => { ...defaultApiHeaders, 'Authorization': 'Bearer access_token', 'X-HEADER': 'X-HEADER' - }) - }) - }) + }); + }); + }); it('returns correct additional headers', () => { const api = new TestApi({ ...config, marketplaceId: MarketplaceId.EBAY_DE - }, req) + }, req); expect(api.additionalHeaders).to.eql({ 'X-EBAY-C-MARKETPLACE-ID': MarketplaceId.EBAY_DE - }) - }) + }); + }); it('returns correct RequestConfig', async () => { // @ts-ignore const api = new TestApi(config, req, { getHeaderAuthorization: sinon.stub().returns({'Authorization': 'Authorization'}) - }) + }); expect(await api.enrichRequestConfig({ - headers: { - 'X-HEADER': 'X-HEADER' + method: 'post', + path: '/', + config: { + headers: { + 'X-HEADER': 'X-HEADER' + } } })).to.eql({ headers: { @@ -105,17 +109,17 @@ describe('Restful API', () => { 'Accept-Encoding': 'application/gzip', 'X-HEADER': 'X-HEADER' } - }) - }) + }); + }); describe('restful response test', () => { it('returns data', async () => { - const post = sinon.stub().returns({ data: {item: '1'} }); + const post = sinon.stub().returns({data: {item: '1'}}); const api = new TestApi(config, {...req, post}); - const response = await api.updateThings() - expect(response).to.eql({item: '1'}) - }) + const response = await api.updateThings(); + expect(response).to.eql({item: '1'}); + }); it('returns response', async () => { const post = sinon.stub().returns({data: {item: '1'}}); @@ -124,7 +128,7 @@ describe('Restful API', () => { const response = await api.updateThings(); expect(response).to.eql({data: {item: '1'}}); }); - }) + }); it('refresh the token if invalid token returned', async () => { const post = sinon.stub().onCall(0).rejects({ @@ -133,7 +137,7 @@ describe('Restful API', () => { error: 'Invalid access token' } } - }).onCall(1).resolves({ data: {updateThings: 'ok'} }); + }).onCall(1).resolves({data: {updateThings: 'ok'}}); const api = new TestApi({ ...config, @@ -146,18 +150,18 @@ describe('Restful API', () => { api.auth.OAuth2.setCredentials(cred); - const result = await api.updateThings() + const result = await api.updateThings(); - expect(post.callCount).to.equal(2) - expect(result).to.eql({updateThings: 'ok'}) - }) + expect(post.callCount).to.equal(2); + expect(result).to.eql({updateThings: 'ok'}); + }); it('refresh the token on PostOrder call if response is 401', async () => { const post = sinon.stub().onCall(0).rejects({ response: { status: 401, } - }).onCall(1).resolves({ data: {updateThings: 'ok'} }); + }).onCall(1).resolves({data: {updateThings: 'ok'}}); const api = new TestApi({ ...config, @@ -172,11 +176,11 @@ describe('Restful API', () => { api.auth.OAuth2.setCredentials(cred); - const result = await api.updateThings() + const result = await api.updateThings(); - expect(post.callCount).to.equal(2) - expect(result).to.eql({updateThings: 'ok'}) - }) + expect(post.callCount).to.equal(2); + expect(result).to.eql({updateThings: 'ok'}); + }); -}) \ No newline at end of file +}); \ No newline at end of file From 342091cd39d001bf7ecfcc85dd51718f1cd55261 Mon Sep 17 00:00:00 2001 From: Daniil T Date: Tue, 27 Dec 2022 17:49:18 +0100 Subject: [PATCH 2/6] chore(release): 8.2.0-RC.0 --- CHANGELOG.md | 7 +++++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bfd448..2f008e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # eBay API Changelog +## [8.2.0-RC.0](https://github.com/hendt/ebay-api/compare/v8.1.0...v8.2.0-RC.0) (2022-12-27) + + +### Features + +* digital signature [#132](https://github.com/hendt/ebay-api/issues/132) ([9aedbb5](https://github.com/hendt/ebay-api/commit/9aedbb50e0f035c2922818674b29fa6be037e3e4)) + ## [8.1.0](https://github.com/hendt/ebay-api/compare/v8.0.1...v8.1.0) (2022-12-27) diff --git a/README.md b/README.md index 1cfbfa6..8f7692f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ It supports `client credentials grant` and `authorization code grant` \(Auth'N'A ## Changelog -* `v8.1.0` is the latest release. +* `v8.2.0-RC.0` is the latest release. * See [here](https://github.com/hendt/ebay-api/blob/master/CHANGELOG.md) for the full changelog. ## Implementation status diff --git a/package-lock.json b/package-lock.json index 3db8d65..080bc08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ebay-api", - "version": "8.1.0", + "version": "8.2.0-RC.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ebay-api", - "version": "8.1.0", + "version": "8.2.0-RC.0", "license": "MIT", "dependencies": { "axios": "^1.2.1", diff --git a/package.json b/package.json index 9b7e40d..366d150 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ebay-api", "author": "Daniil Tomilow", - "version": "8.1.0", + "version": "8.2.0-RC.0", "description": "eBay API for Node and Browser", "type": "module", "main": "./lib/index.js", From de1b4669d37fd5a1c9bae0259a4d5bac18ac7e7f Mon Sep 17 00:00:00 2001 From: Daniil T Date: Tue, 27 Dec 2022 17:54:50 +0100 Subject: [PATCH 3/6] feat: add setSignature method --- src/eBayApi.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/eBayApi.ts b/src/eBayApi.ts index db5d6e1..b8fd1d8 100644 --- a/src/eBayApi.ts +++ b/src/eBayApi.ts @@ -148,6 +148,10 @@ export default class eBayApi extends Api { get clientAlerts(): ClientAlerts { return this._clientAlerts || (this._clientAlerts = this.factory.createClientAlertsApi()); } + + setSignature(signature: Signature) { + this.config.signature = signature; + } } export { From 314211bdb6f90a6c2cb6efd66845d85096a39d48 Mon Sep 17 00:00:00 2001 From: Daniil T Date: Fri, 27 Jan 2023 20:14:58 +0100 Subject: [PATCH 4/6] fix: show error message in browser since digital signature is not supported here --- rollup.config.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/rollup.config.js b/rollup.config.js index 2f01299..14cfb1a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,6 +8,9 @@ import virtual from '@rollup/plugin-virtual'; const pkg = require('./package.json'); const plugins = [ + virtual({ + crypto: `export function createHash() { throw Error('crypto.createHash is not supported in browser.'); }; export function sign() { throw Error('crypto.sign is not supported in browser.'); };`, + }), bundleSize(), resolve({ browser: true @@ -44,10 +47,5 @@ export default [{ }, ], context: 'window', - plugins: [ - virtual({ - crypto: `export function createHash() { return window.crypto.createHash(...arguments); }; export function sign() { return window.crypto.sign(...arguments); };`, - }), - ...plugins - ] + plugins }] From f254e9ea3f19fe42e0be35b5fbd181257e73bb13 Mon Sep 17 00:00:00 2001 From: Daniil T Date: Fri, 27 Jan 2023 20:24:10 +0100 Subject: [PATCH 5/6] chore: update README.md --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 7f5e02a..06e82ee 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,41 @@ app.get('/orders/:id', async function (req, res) { }); ``` +## Digital Signature +Signatures are required when the call is made for EU- or UK-domiciled sellers, and only for the following APIs/methods: + +* All methods in the Finances API +* issueRefund in the Fulfillment API +* GetAccount in the Trading API +* The following methods in the Post-Order API: + - Issue Inquiry Refund + - Issue case refund + - Issue return refund + - Process Return Request + - Create Cancellation Request + - Approve Cancellation Request + +### How to use Digital Signature +```js +// 1. Create singning key and save it appropriatly +const signingKey = await eBay.developer.keyManagement.createSigningKey('ED25519'); +// 2. Set the signature +eBay.setSignature(signingKey) +// or in constructor +eBay = new eBayApi({ + appId: '...', + certId: '...', + signature: { + jwe: signingKey.jwe, + privateKey: signingKey.privateKey + } +}); +// 3. Use the 'sign' keyword in Restful API +const summary = await eBay.sell.finances.sign.getSellerFundsSummary(); +// 3. Or the 'sign' parameter in traditional API +const account = await eBay.trading.GetAccount(null, {sign: true}); +``` + ## RESTful API ### How to set the Scope From a3ceaab2c9d9cae5cf1fe8deb49fc1ccd0313362 Mon Sep 17 00:00:00 2001 From: Daniil T Date: Fri, 27 Jan 2023 20:32:26 +0100 Subject: [PATCH 6/6] fix: remove unused globals crypto --- rollup.config.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/rollup.config.js b/rollup.config.js index 14cfb1a..25607ea 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -31,9 +31,6 @@ export default [{ name: 'eBayApi', exports: 'default', sourcemap: false, - globals: { - crypto: 'crypto' - } }, ], plugins