Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add oauth2 for webhook2.0 #2334

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
26 changes: 26 additions & 0 deletions packages/core/src/__tests__/schema-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,30 @@ describe('validateSchema', () => {
expect(validateSchema(less_than_min_payload, min_max_schema, { throwIfInvalid: false })).toBeFalsy()
expect(validateSchema(greater_than_max_payload, min_max_schema, { throwIfInvalid: false })).toBeFalsy()
})

it('should allow exempted properties', () => {
const payload = {
a: 'a',
b: {
anything: 'goes'
},
exemptKey: {
nested: 'nested'
}
}

validateSchema(payload, schema, { schemaKey: `testSchema`, exempt: ['exemptKey'] })
expect(payload).toHaveProperty('exemptKey')
expect(payload).toMatchInlineSnapshot(`
Object {
"a": "a",
"b": Object {
"anything": "goes",
},
"exemptKey": Object {
"nested": "nested",
},
}
`)
})
})
13 changes: 10 additions & 3 deletions packages/core/src/destination-kit/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,11 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
// Validate the resolved payload against the schema
if (this.schema) {
const schemaKey = `${this.destinationName}:${this.definition.title}`
validateSchema(payload, this.schema, { schemaKey, statsContext: bundle.statsContext })
validateSchema(payload, this.schema, {
schemaKey,
statsContext: bundle.statsContext,
exempt: ['dynamicAuthSettings']
})
results.push({ output: 'Payload validated' })
}

Expand Down Expand Up @@ -382,7 +386,8 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
const validationOptions = {
schemaKey: `${this.destinationName}:${this.definition.title}`,
throwIfInvalid: true,
statsContext: bundle.statsContext
statsContext: bundle.statsContext,
exempt: ['dynamicAuthSettings']
}

// Filter out invalid payloads before sending them to the action
Expand Down Expand Up @@ -642,7 +647,9 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =

if (this.hookSchemas?.[hookType]) {
const schema = this.hookSchemas[hookType]
validateSchema(data.hookInputs, schema)
validateSchema(data.hookInputs, schema, {
exempt: ['dynamicAuthSettings']
})
}

return (await this.performRequest(hookFn, data)) as ActionHookResponse<any>
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/destination-kit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,10 @@ export class Destination<Settings = JSONObject, AudienceSettings = JSONObject> {
validateSettings(settings: Settings): void {
if (this.settingsSchema) {
try {
validateSchema(settings, this.settingsSchema, { schemaKey: `${this.name}:settings` })
validateSchema(settings, this.settingsSchema, {
schemaKey: `${this.name}:settings`,
exempt: ['dynamicAuthSettings']
})
} catch (err) {
const error = err as ResponseError
if (error.name === 'AggregateAjvError' || error.name === 'ValidationError') {
Expand All @@ -452,7 +455,9 @@ export class Destination<Settings = JSONObject, AudienceSettings = JSONObject> {
}
//validate audienceField Input
if (createAudienceInput.audienceSettings && Object.keys(createAudienceInput.audienceSettings).length > 0) {
validateSchema(createAudienceInput.audienceSettings, fieldsToJsonSchema(audienceFields))
validateSchema(createAudienceInput.audienceSettings, fieldsToJsonSchema(audienceFields), {
exempt: ['dynamicAuthSettings']
cyberlord29 marked this conversation as resolved.
Show resolved Hide resolved
})
}
const destinationSettings = this.getDestinationSettings(settings)
const run = async () => {
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/schema-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,23 @@ interface ValidationOptions {
schemaKey?: string
throwIfInvalid?: boolean
statsContext?: StatsContext
exempt?: string[]
}

/**
* Validates an object against a json schema
* and caches the schema for subsequent validations when a key is provided
*/
export function validateSchema(obj: unknown, schema: JSONSchema4, options?: ValidationOptions) {
const { schemaKey, throwIfInvalid = true, statsContext } = options ?? {}
const { schemaKey, throwIfInvalid = true, statsContext, exempt = [] } = options ?? {}
let validate: ValidateFunction
const exemptedFields: Record<string, unknown> = {}

// save exempted fields
const objCopy = { ...(obj as Record<string, unknown>) }
exempt.forEach((prop) => {
exemptedFields[prop] = objCopy[prop]
})

if (schemaKey) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Expand All @@ -64,6 +72,13 @@ export function validateSchema(obj: unknown, schema: JSONSchema4, options?: Vali
arrifyFields(obj, schema)
const isValid = validate(obj)

// add exempted fields back
exempt.forEach((prop) => {
if (objCopy[prop] !== undefined) {
;(obj as Record<string, unknown>)[prop] = exemptedFields[prop]
}
})

if (throwIfInvalid && !isValid && validate.errors) {
statsContext?.statsClient?.incr('ajv.discard', 1, statsContext.tags)
throw new AggregateAjvError(validate.errors)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import {
DestinationDefinition
} from '@segment/actions-core'
import Webhook from '../index'
import { createHmac, timingSafeEqual } from 'crypto'
import { SegmentEvent } from '@segment/actions-core'

// Exported so we can re-use to test webhook-audiences
export const baseWebhookTests = (def: DestinationDefinition<any>) => {
Expand Down Expand Up @@ -57,102 +55,6 @@ export const baseWebhookTests = (def: DestinationDefinition<any>) => {
expect(responses[0].status).toBe(200)
})

it('supports request signing', async () => {
const url = 'https://example.com'
const event = createTestEvent({
properties: { cool: true }
})
const payload = JSON.stringify(event.properties)
const sharedSecret = 'abc123'

nock(url)
.post('/', payload)
.reply(async function (_uri, body) {
// Normally you should use the raw body but nock automatically
// deserializes it (and doesn't allow us to access the raw request
// body) so we re-serialize the body here so that we can demonstrate
// signture validation.
const bodyString = JSON.stringify(body)

// Validate the signature
const expectSignature = this.req.headers['x-signature'][0]
const actualSignature = createHmac('sha1', sharedSecret).update(bodyString).digest('hex')

// Use constant-time comparison to avoid timing attacks
if (
expectSignature.length !== actualSignature.length ||
!timingSafeEqual(Buffer.from(actualSignature, 'hex'), Buffer.from(expectSignature, 'hex'))
) {
return [400, 'Invalid signature']
}

return [200, 'OK']
})

const responses = await testDestination.testAction('send', {
event,
mapping: {
url,
data: { '@path': '$.properties' }
},
settings: { sharedSecret },
useDefaultMappings: true
})

expect(responses.length).toBe(1)
expect(responses[0].status).toBe(200)
})

it('supports request signing with batched events', async () => {
const url = 'https://example.com'

const events: SegmentEvent[] = [
createTestEvent({
properties: { cool: false }
}),
createTestEvent({
properties: { cool: true }
})
]

const payload = JSON.stringify(events.map(({ properties }) => properties))
const sharedSecret = 'abc123'
nock(url)
.post('/', payload)
.reply(async function (_uri, body: any) {
// Normally you should use the raw body but nock automatically
// deserializes it (and doesn't allow us to access the raw request
// body) so we re-serialize the body here so that we can demonstrate
// signture validation

// Validate the signature
const expectSignature = this.req.headers['x-signature'][0]
const actualSignature = createHmac('sha1', sharedSecret).update(JSON.stringify(body[0])).digest('hex')

// Use constant-time comparison to avoid timing attacks
if (
expectSignature.length !== actualSignature.length ||
!timingSafeEqual(Buffer.from(actualSignature, 'hex'), Buffer.from(expectSignature, 'hex'))
) {
return [400, 'Invalid signature123']
}

return [200, 'OK']
})

const responses = await testDestination.testBatchAction('send', {
events,
mapping: {
url,
data: { '@path': '$.properties' }
},
settings: { sharedSecret },
useDefaultMappings: true
})
expect(responses.length).toBe(1)
expect(responses[0].status).toBe(200)
})

it('should throw an error when header value is invalid', async () => {
const url = 'https://example.build'
const event = createTestEvent()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,47 @@
import type { DestinationDefinition } from '@segment/actions-core'
import type { Settings } from './generated-types'
import { createHmac } from 'crypto'
import { RefreshTokenResponse } from './types'

import send from './send'

const destination: DestinationDefinition<Settings> = {
type SettingsWithDynamicAuth = Settings & {
dynamicAuthSettings: any
}
const destination: DestinationDefinition<SettingsWithDynamicAuth> = {
name: 'Extensible Webhook',
slug: 'actions-webhook-extensible',
mode: 'cloud',
authentication: {
scheme: 'custom',
scheme: 'oauth2',
fields: {
sharedSecret: {
type: 'string',
label: 'Shared Secret',
description:
'If set, Segment will sign requests with an HMAC in the "X-Signature" request header. The HMAC is a hex-encoded SHA1 hash generated using this shared secret and the request body.'
}
},
refreshAccessToken: async (request, { settings, auth }) => {
const res = await request<RefreshTokenResponse>(settings.dynamicAuthSettings.oauth.refreshTokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(auth.clientId + ':' + auth.clientSecret).toString('base64')}`
},
body: '{"grant_type":"client_credentials"}'
})

return { accessToken: res.data.access_token }
}
},
extendRequest: ({ settings, payload }) => {
const payloadData = payload.length ? payload[0]['data'] : payload['data']
if (settings.sharedSecret && payloadData) {
const digest = createHmac('sha1', settings.sharedSecret).update(JSON.stringify(payloadData), 'utf8').digest('hex')
return { headers: { 'X-Signature': digest } }
extendRequest: ({ settings }) => {
const { dynamicAuthSettings } = settings
const accessToken = dynamicAuthSettings?.oauth?.access?.access_token
return {
headers: {
authorization: `Bearer ${accessToken}`
cyberlord29 marked this conversation as resolved.
Show resolved Hide resolved
}
}
return {}
},
actions: {
send
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,21 @@ const action: ActionDefinition<Settings, Payload> = {
},
perform: (request, { payload }) => {
try {
let body
let contentType = 'application/json'

if (payload.headers) {
contentType = (payload.headers['Content-Type'] as string) || (payload.headers['content-type'] as string)
}

if (payload.data) {
body = encodeBody(payload.data, contentType)
}

return request(payload.url, {
method: payload.method as RequestMethod,
headers: payload.headers as Record<string, string>,
json: payload.data
...body
})
} catch (error) {
if (error instanceof TypeError) throw new PayloadValidationError(error.message)
Expand All @@ -61,12 +72,21 @@ const action: ActionDefinition<Settings, Payload> = {
},
performBatch: (request, { payload }) => {
// Expect these to be the same across the payloads
const { url, method, headers } = payload[0]
try {
const { url, method, headers } = payload[0]
return request(url, {
method: method as RequestMethod,
headers: headers as Record<string, string>,
json: payload.map(({ data }) => data)
json: payload.map(({ data }) => {
let contentType = 'application/json'

if (headers) {
contentType = (headers['Content-Type'] as string) || (headers['content-type'] as string)
}
if (data) return encodeBody(data, contentType)

return data
})
})
} catch (error) {
if (error instanceof TypeError) throw new PayloadValidationError(error.message)
Expand All @@ -75,4 +95,16 @@ const action: ActionDefinition<Settings, Payload> = {
}
}

const encodeBody = (payload: Record<string, any>, contentType: string) => {
if (contentType === 'application/json') {
return { json: payload }
} else if (contentType === 'application/x-www-form-urlencoded') {
const formUrlEncoded = new URLSearchParams(payload as Record<string, string>).toString()
return { body: formUrlEncoded }
} else {
// Handle other content types or default case
return { json: payload }
}
}

export default action
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface RefreshTokenResponse {
access_token: string
scope: string
expires_in: number
token_type: string
}
Loading
Loading