From 5f8780eb6afb9dafbbd83976498bfad29f8a8a41 Mon Sep 17 00:00:00 2001 From: "balint.bozoki" Date: Tue, 28 Jan 2025 16:03:05 +0100 Subject: [PATCH] escher authentication --- .../__snapshots__/snapshot.test.ts.snap | 4 - .../src/destinations/antavo/escher/escher.ts | 241 ++++++++++++++++++ .../__snapshots__/snapshot.test.ts.snap | 2 - .../antavo/event/__tests__/event.test.ts | 12 +- .../src/destinations/antavo/event/index.ts | 21 +- .../destinations/antavo/generated-types.ts | 4 + .../src/destinations/antavo/index.ts | 6 + .../__snapshots__/snapshot.test.ts.snap | 2 - .../antavo/profile/__tests__/profile.test.ts | 12 +- .../src/destinations/antavo/profile/index.ts | 25 +- 10 files changed, 293 insertions(+), 36 deletions(-) create mode 100644 packages/destination-actions/src/destinations/antavo/escher/escher.ts diff --git a/packages/destination-actions/src/destinations/antavo/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/antavo/__tests__/__snapshots__/snapshot.test.ts.snap index 5c3a6d1c32..e533c0a881 100644 --- a/packages/destination-actions/src/destinations/antavo/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/antavo/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,7 +4,6 @@ exports[`Testing snapshot for actions-antavo destination: event action - all fie Object { "account": "YOUfi0^J9NW]3LPm", "action": "YOUfi0^J9NW]3LPm", - "api_key": "YOUfi0^J9NW]3LPm", "customer": "YOUfi0^J9NW]3LPm", "data": Object { "testType": "YOUfi0^J9NW]3LPm", @@ -15,7 +14,6 @@ Object { exports[`Testing snapshot for actions-antavo destination: event action - required fields 1`] = ` Object { "action": "YOUfi0^J9NW]3LPm", - "api_key": "YOUfi0^J9NW]3LPm", "customer": "YOUfi0^J9NW]3LPm", } `; @@ -24,7 +22,6 @@ exports[`Testing snapshot for actions-antavo destination: profile action - all f Object { "account": "0$ZK&EN", "action": "profile", - "api_key": "0$ZK&EN", "customer": "0$ZK&EN", "data": Object { "birth_date": "0$ZK&EN", @@ -42,7 +39,6 @@ Object { exports[`Testing snapshot for actions-antavo destination: profile action - required fields 1`] = ` Object { "action": "profile", - "api_key": "0$ZK&EN", "customer": "0$ZK&EN", "data": Object {}, } diff --git a/packages/destination-actions/src/destinations/antavo/escher/escher.ts b/packages/destination-actions/src/destinations/antavo/escher/escher.ts new file mode 100644 index 0000000000..56226fe306 --- /dev/null +++ b/packages/destination-actions/src/destinations/antavo/escher/escher.ts @@ -0,0 +1,241 @@ +import url from 'url' +import path from 'path' +import crypto from 'crypto' + +export type acceptedMethods = + 'delete' + | 'get' + | 'post' + | 'put' + | 'patch' + | 'head' + | 'GET' + | 'POST' + | 'PUT' + | 'DELETE' + | 'PATCH' + | 'HEAD' + +export class Escher { + private config: { + algorithmPrefix: string, + vendorKey: string + hashAlgorithm: string + credentialScope: string + authHeaderName: string + dateHeaderName: string + clockSkew: number + accessKeyId: string + apiSecret: string + } + + public constructor( + environment: string, + apiKey: string, + apiSecret: string + ) { + const credentialScope: string = environment + '/api/antavo_request' + this.config = { + algorithmPrefix: 'ANTAVO', + vendorKey: 'Antavo', + hashAlgorithm: 'SHA256', + credentialScope: credentialScope, + authHeaderName: 'authorization', + dateHeaderName: 'date', + clockSkew: 300, + accessKeyId: apiKey, + apiSecret: apiSecret + } + } + + public signRequest( + requestOptions: { + headers: Record, + method: acceptedMethods, + host: string, + url: string + }, + body: any + ): { + headers: Record, + method: acceptedMethods, + host: string, + url: string, + json: string[], + } { + const currentDate = new Date() + const formattedDate = this.toLongDate(currentDate) + const headersMap: Record = {} + const bodyJson = JSON.stringify(body) + + requestOptions['headers']['host'] = requestOptions.host + requestOptions['headers']['date'] = formattedDate + + headersMap[this.config.dateHeaderName] = formattedDate + headersMap[this.config.authHeaderName] = this.generateAuthHeader(requestOptions, bodyJson, currentDate) + + return { + headers: headersMap, + method: requestOptions.method, + host: requestOptions.host, + url: requestOptions.url, + json: body + } + } + + private generateAuthHeader( + requestOptions: { + headers: Record, + method: acceptedMethods, + host: string, + url: string + }, + body: string, + currentDate: Date + ): string { + const algorithm = [ + this.config.algorithmPrefix, + 'HMAC', + this.config.hashAlgorithm + ].join('-') + + const fullCredentials = [ + this.config.accessKeyId, + this.toShortDate(currentDate), + this.config.credentialScope + ].join('/') + + const signedHeaders = this.formatSignedHeaders(Object.keys(requestOptions.headers)) + + const signKey = this.calculateSigningKey(currentDate) + const stringToSign = this.getStringToSign(requestOptions, body, currentDate) + + const signature = crypto + .createHmac(this.config.hashAlgorithm, signKey) + .update(stringToSign, 'utf8') + .digest('hex') + + return ( + algorithm + + ' Credential=' + + fullCredentials + + ', SignedHeaders=' + + signedHeaders + + ', Signature=' + + signature + ) + } + + private canonicalizeQuery(query: string): string { + if (query === '') { + return '' + } + + const encodeComponent = (component: string): string => + encodeURIComponent(component) + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + + const join = (key: string, value: string): string => encodeComponent(key) + '=' + encodeComponent(value) + let queryMap: Record = {} + + query.split('&').forEach(value => { + let query = value.split('=') + queryMap[query[0]] = query[1] + }) + + return Object.keys(queryMap) + .map(key => { + return join(key, queryMap[key]) + }) + .sort() + .join('&') + } + + + private canonicalizeRequest( + requestOptions: { + url: string; + method: acceptedMethods; + headers: Record + }, + body: string + ): string { + const preparedUrl = requestOptions.url + .replace('#', '%23') + .replace('\\', '%5C') + .replace('+', '%20') + .replace('%2B', '%20') + const parsedUrl = url.parse(preparedUrl) + const headers = Object.keys(requestOptions.headers).sort() + const method = !(typeof requestOptions.method === 'undefined') ? requestOptions.method.toUpperCase() : '' + const canonicalizedHeaders = headers.map(key => key + ':' + requestOptions.headers[key]) + const lines = [ + method, + path.posix.normalize(parsedUrl.pathname ?? ''), + this.canonicalizeQuery(parsedUrl.query ?? ''), + canonicalizedHeaders.join('\n'), + '', + headers.join(';'), + this.hash(this.config.hashAlgorithm, body) + ] + return lines.join('\n') + } + + private getStringToSign( + requestOptions: { + url: string; + method: acceptedMethods; + headers: Record + }, + body: string, + currentDate: Date + ): string { + return [ + `${this.config.algorithmPrefix}-HMAC-${this.config.hashAlgorithm}`, + this.toLongDate(currentDate), + `${this.toShortDate(currentDate)}/${this.config.credentialScope}`, + this.hash( + this.config.hashAlgorithm, + this.canonicalizeRequest(requestOptions, body) + ) + ].join('\n') + } + + private calculateSigningKey(currentDate: Date): string { + let signingKey: any = this.config.algorithmPrefix + this.config.apiSecret + const authKeyParts = [this.toShortDate(currentDate), ...this.config.credentialScope.split(/\//g)] + authKeyParts.forEach(data => { + signingKey = crypto.createHmac(this.config.hashAlgorithm, signingKey).update(data, 'utf8').digest() + }) + + return signingKey + } + + private toLongDate(date: Date): string { + return date + .toISOString() + .replace(/-/g, '') + .replace(/:/g, '') + .replace(/\..*Z/, 'Z') + } + + private toShortDate(date: Date): string { + return this.toLongDate(date).substring(0, 8) + } + + private hash(hashAlgorithm: string, string: string): string { + return crypto + .createHash(hashAlgorithm) + .update(string, 'utf8') + .digest('hex') + } + + private formatSignedHeaders(signedHeaders: string[]): string { + return signedHeaders + .map(signedHeader => signedHeader.toLowerCase()) + .sort() + .join(';') + } +} diff --git a/packages/destination-actions/src/destinations/antavo/event/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/antavo/event/__tests__/__snapshots__/snapshot.test.ts.snap index 4bf08a0874..548ec79ef5 100644 --- a/packages/destination-actions/src/destinations/antavo/event/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/antavo/event/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,7 +4,6 @@ exports[`Testing snapshot for Antavo's event destination action: all fields 1`] Object { "account": "IB60PM0Tc", "action": "IB60PM0Tc", - "api_key": "IB60PM0Tc", "customer": "IB60PM0Tc", "data": Object { "testType": "IB60PM0Tc", @@ -15,7 +14,6 @@ Object { exports[`Testing snapshot for Antavo's event destination action: required fields 1`] = ` Object { "action": "IB60PM0Tc", - "api_key": "IB60PM0Tc", "customer": "IB60PM0Tc", } `; diff --git a/packages/destination-actions/src/destinations/antavo/event/__tests__/event.test.ts b/packages/destination-actions/src/destinations/antavo/event/__tests__/event.test.ts index 8df4c9549c..2c9ab26d01 100644 --- a/packages/destination-actions/src/destinations/antavo/event/__tests__/event.test.ts +++ b/packages/destination-actions/src/destinations/antavo/event/__tests__/event.test.ts @@ -5,7 +5,8 @@ import destination from '../../index' const testDestination = createTestIntegration(destination) const settings = { stack: 'test-stack', - api_key: 'testApiKey' + api_key: 'testApiKey', + api_secret: 'testApiSecret' } describe('Antavo (Actions)', () => { @@ -53,8 +54,7 @@ describe('Antavo (Actions)', () => { account: 'testAccount', data: { points: 1234 - }, - api_key: 'testApiKey' + } }) }) it('Handle request without default mappings', async () => { @@ -94,8 +94,7 @@ describe('Antavo (Actions)', () => { account: 'testAccount', data: { points: 1234 - }, - api_key: 'testApiKey' + } }) }) it('Handle request without optional fields', async () => { @@ -125,8 +124,7 @@ describe('Antavo (Actions)', () => { expect(responses[0].status).toBe(202) expect(responses[0].options.json).toMatchObject({ customer: 'testUser', - action: 'testAction', - api_key: 'testApiKey' + action: 'testAction' }) }) it('Throw error for missing required field: customer', async () => { diff --git a/packages/destination-actions/src/destinations/antavo/event/index.ts b/packages/destination-actions/src/destinations/antavo/event/index.ts index 7aa197093a..244c99051c 100644 --- a/packages/destination-actions/src/destinations/antavo/event/index.ts +++ b/packages/destination-actions/src/destinations/antavo/event/index.ts @@ -1,6 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' +import { acceptedMethods, Escher } from '../escher/escher' const action: ActionDefinition = { title: 'Loyalty events', @@ -36,16 +37,24 @@ const action: ActionDefinition = { } }, perform: (request, data) => { + const escher = new Escher( + data.settings.stack, + data.settings.api_key, + data.settings.api_secret + ) const url = `https://api.${data.settings.stack}.antavo.com/v1/webhook/segment` const payload = { - ...data.payload, - api_key: data.settings.api_key + ...data.payload } + const options = { + headers: {}, + host: `api.${data.settings.stack}.antavo.com`, + method: 'POST' as acceptedMethods, + url: url + } + const signedOptions = escher.signRequest(options, payload) - return request(url, { - method: 'post', - json: payload - }) + return request(url, signedOptions) } } diff --git a/packages/destination-actions/src/destinations/antavo/generated-types.ts b/packages/destination-actions/src/destinations/antavo/generated-types.ts index 400d7a3a92..d9b0bf18d3 100644 --- a/packages/destination-actions/src/destinations/antavo/generated-types.ts +++ b/packages/destination-actions/src/destinations/antavo/generated-types.ts @@ -9,4 +9,8 @@ export interface Settings { * The Antavo brand API key supplied to your brand in Antavo Loyalty Engine */ api_key: string + /** + * Antavo brand API secret + */ + api_secret: string } diff --git a/packages/destination-actions/src/destinations/antavo/index.ts b/packages/destination-actions/src/destinations/antavo/index.ts index 3d79d1ad83..3e80ff2ac5 100644 --- a/packages/destination-actions/src/destinations/antavo/index.ts +++ b/packages/destination-actions/src/destinations/antavo/index.ts @@ -22,6 +22,12 @@ const destination: DestinationDefinition = { description: 'The Antavo brand API key supplied to your brand in Antavo Loyalty Engine', type: 'password', required: true + }, + api_secret: { + label: 'API Secret', + description: 'Antavo brand API secret', + type: 'password', + required: true } } }, diff --git a/packages/destination-actions/src/destinations/antavo/profile/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/antavo/profile/__tests__/__snapshots__/snapshot.test.ts.snap index 1bc641c447..73f15bde5c 100644 --- a/packages/destination-actions/src/destinations/antavo/profile/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/antavo/profile/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,7 +4,6 @@ exports[`Testing snapshot for Antavo's profile destination action: all fields 1` Object { "account": "x7T1S", "action": "profile", - "api_key": "x7T1S", "customer": "x7T1S", "data": Object { "birth_date": "x7T1S", @@ -22,7 +21,6 @@ Object { exports[`Testing snapshot for Antavo's profile destination action: required fields 1`] = ` Object { "action": "profile", - "api_key": "x7T1S", "customer": "x7T1S", "data": Object {}, } diff --git a/packages/destination-actions/src/destinations/antavo/profile/__tests__/profile.test.ts b/packages/destination-actions/src/destinations/antavo/profile/__tests__/profile.test.ts index edab8408d3..b5d249690b 100644 --- a/packages/destination-actions/src/destinations/antavo/profile/__tests__/profile.test.ts +++ b/packages/destination-actions/src/destinations/antavo/profile/__tests__/profile.test.ts @@ -5,7 +5,8 @@ import destination from '../../index' const testDestination = createTestIntegration(destination) const settings = { stack: 'test-stack', - api_key: 'testApiKey' + api_key: 'testApiKey', + api_secret: 'testApiSecret' } describe('Antavo (Actions)', () => { @@ -72,8 +73,7 @@ describe('Antavo (Actions)', () => { phone: '123456', mobile_phone: '654321' }, - action: 'profile', - api_key: 'testApiKey' + action: 'profile' }) }) it('Handle request without default mappings', async () => { @@ -132,8 +132,7 @@ describe('Antavo (Actions)', () => { phone: '123456', mobile_phone: '654321' }, - action: 'profile', - api_key: 'testApiKey' + action: 'profile' }) }) it('Handle request without optional fields', async () => { @@ -189,8 +188,7 @@ describe('Antavo (Actions)', () => { phone: '123456', mobile_phone: '654321' }, - action: 'profile', - api_key: 'testApiKey' + action: 'profile' }) }) it('Throw error for missing required field: customer', async () => { diff --git a/packages/destination-actions/src/destinations/antavo/profile/index.ts b/packages/destination-actions/src/destinations/antavo/profile/index.ts index 52cd5aab15..0eb630462f 100644 --- a/packages/destination-actions/src/destinations/antavo/profile/index.ts +++ b/packages/destination-actions/src/destinations/antavo/profile/index.ts @@ -1,6 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' +import { acceptedMethods, Escher } from '../escher/escher' const action: ActionDefinition = { title: 'Profile updates', @@ -70,7 +71,7 @@ const action: ActionDefinition = { label: 'Mobile phone', description: 'Customer\'s mobile phone number', type: 'string' - }, + } }, default: { first_name: { @@ -98,20 +99,28 @@ const action: ActionDefinition = { '@path': '$.traits.mobile_phone' } } - }, + } }, perform: (request, data) => { + const escher = new Escher( + data.settings.stack, + data.settings.api_key, + data.settings.api_secret + ) const url = `https://api.${data.settings.stack}.antavo.com/v1/webhook/segment` const payload = { ...data.payload, - action: 'profile', - api_key: data.settings.api_key + action: 'profile' + } + const options = { + headers: {}, + host: `api.${data.settings.stack}.antavo.com`, + method: 'POST' as acceptedMethods, + url: url } + const signedOptions = escher.signRequest(options, payload) - return request(url, { - method: 'post', - json: payload - }) + return request(url, signedOptions) } }